qdadm 0.34.0 → 0.35.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -77,12 +77,12 @@ export class EntitiesCollector extends Collector {
77
77
  })
78
78
  this._signalCleanups.push(entityCleanup)
79
79
 
80
- // Listen to cache invalidation
81
- const cacheCleanup = signals.on('cache:entity:invalidated', () => {
80
+ // Listen to entity data changes (CRUD operations)
81
+ const dataCleanup = signals.on('entity:data-invalidate', () => {
82
82
  this._lastUpdate = Date.now()
83
83
  this.notifyChange()
84
84
  })
85
- this._signalCleanups.push(cacheCleanup)
85
+ this._signalCleanups.push(dataCleanup)
86
86
  }
87
87
 
88
88
  /**
@@ -248,6 +248,9 @@ export class EntitiesCollector extends Collector {
248
248
  canList: manager.canList?.() ?? true
249
249
  },
250
250
 
251
+ // Auth sensitivity (auto-invalidates on auth events)
252
+ authSensitive: manager._authSensitive ?? false,
253
+
251
254
  // Warmup
252
255
  warmup: {
253
256
  enabled: manager.warmupEnabled ?? false
@@ -18,8 +18,8 @@ import { Collector } from './Collector.js'
18
18
  * Collector for SignalBus events
19
19
  *
20
20
  * Records all signals with their name, data, and source for debugging.
21
- * Uses the `*:*` wildcard pattern to capture all signals following the
22
- * domain:action naming convention.
21
+ * Uses the `**` wildcard pattern to capture all signals including
22
+ * multi-segment names (e.g., entity:data-invalidate, auth:impersonate:start).
23
23
  */
24
24
  export class SignalCollector extends Collector {
25
25
  /**
@@ -43,8 +43,8 @@ export class SignalCollector extends Collector {
43
43
 
44
44
  // Subscribe to all signals using wildcard pattern
45
45
  // QuarKernel supports wildcards with the configured delimiter (:)
46
- // '*:*' matches any domain:action signal
47
- this._unsubscribe = ctx.signals.on('*:*', (event) => {
46
+ // '**' matches all signals including multi-segment (entity:data-invalidate)
47
+ this._unsubscribe = ctx.signals.on('**', (event) => {
48
48
  this.record({
49
49
  name: event.name,
50
50
  data: event.data,
@@ -12,7 +12,7 @@
12
12
  import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
13
13
  import Badge from 'primevue/badge'
14
14
  import Button from 'primevue/button'
15
- import { ZonesPanel, AuthPanel, EntitiesPanel, ToastsPanel, EntriesPanel } from './panels'
15
+ import { ZonesPanel, AuthPanel, EntitiesPanel, ToastsPanel, EntriesPanel, SignalsPanel } from './panels'
16
16
 
17
17
  const props = defineProps({
18
18
  bridge: { type: Object, required: true },
@@ -658,6 +658,13 @@ function getCollectorColor(name) {
658
658
  @update="notifyBridge"
659
659
  />
660
660
 
661
+ <!-- Signals collector -->
662
+ <SignalsPanel
663
+ v-else-if="currentCollector.name === 'signals' || currentCollector.name === 'SignalCollector'"
664
+ :collector="currentCollector.collector"
665
+ :entries="currentCollector.entries"
666
+ />
667
+
661
668
  <div v-else-if="currentCollector.entries.length === 0" class="debug-empty">
662
669
  <i class="pi pi-inbox" />
663
670
  <span>No entries</span>
@@ -138,6 +138,11 @@ function getCapabilityLabel(cap) {
138
138
  class="pi pi-eye perm-icon-readonly"
139
139
  title="Read-only entity"
140
140
  />
141
+ <i
142
+ v-if="entity.authSensitive"
143
+ class="pi pi-shield perm-icon-auth-sensitive"
144
+ title="Auth-sensitive (auto-invalidates on auth events)"
145
+ />
141
146
  </div>
142
147
  <span class="entity-label">{{ entity.label }}</span>
143
148
  <span v-if="entity.cache.enabled" class="entity-cache" :class="{ 'entity-cache-valid': entity.cache.valid }">
@@ -397,6 +402,10 @@ function getCapabilityLabel(cap) {
397
402
  color: #f59e0b;
398
403
  margin-left: 2px;
399
404
  }
405
+ .perm-icon-auth-sensitive {
406
+ color: #8b5cf6;
407
+ margin-left: 2px;
408
+ }
400
409
  .entity-label {
401
410
  color: #a1a1aa;
402
411
  font-size: 11px;
@@ -46,6 +46,7 @@ async function copyEntry(entry, idx) {
46
46
  :class="{ 'entry-new': entry._isNew }"
47
47
  >
48
48
  <div class="entry-meta">
49
+ <span v-if="entry._isNew" class="entry-new-dot" title="New (unseen)" />
49
50
  <span class="entry-time">{{ formatTime(entry.timestamp) }}</span>
50
51
  <span v-if="entry.name" class="entry-name">{{ entry.name }}</span>
51
52
  </div>
@@ -62,6 +63,7 @@ async function copyEntry(entry, idx) {
62
63
  :class="{ 'entry-new': entry._isNew }"
63
64
  >
64
65
  <div class="entry-header">
66
+ <span v-if="entry._isNew" class="entry-new-dot" title="New (unseen)" />
65
67
  <span class="entry-time">{{ formatTime(entry.timestamp) }}</span>
66
68
  <span v-if="entry.name" class="entry-name">{{ entry.name }}</span>
67
69
  <span v-if="entry.message" class="entry-message">{{ entry.message }}</span>
@@ -98,9 +100,6 @@ async function copyEntry(entry, idx) {
98
100
  .entry-h:last-child {
99
101
  border-right: none;
100
102
  }
101
- .entry-h.entry-new {
102
- border-left: 3px solid #8b5cf6;
103
- }
104
103
 
105
104
  /* Vertical entries (right/fullscreen mode) */
106
105
  .entries-v {
@@ -118,7 +117,7 @@ async function copyEntry(entry, idx) {
118
117
  /* New entry indicator */
119
118
  .entry-new {
120
119
  position: relative;
121
- background: rgba(139, 92, 246, 0.08);
120
+ background: rgba(245, 158, 11, 0.08);
122
121
  }
123
122
  .entry-v.entry-new::before {
124
123
  content: '';
@@ -127,7 +126,20 @@ async function copyEntry(entry, idx) {
127
126
  top: 0;
128
127
  bottom: 0;
129
128
  width: 3px;
130
- background: #8b5cf6;
129
+ background: #f59e0b;
130
+ }
131
+ .entry-h.entry-new {
132
+ border-left: 3px solid #f59e0b;
133
+ }
134
+
135
+ .entry-new-dot {
136
+ display: inline-block;
137
+ width: 6px;
138
+ height: 6px;
139
+ background: #f59e0b;
140
+ border-radius: 50%;
141
+ margin-right: 4px;
142
+ flex-shrink: 0;
131
143
  }
132
144
 
133
145
  /* Entry parts */
@@ -0,0 +1,335 @@
1
+ <script setup>
2
+ /**
3
+ * SignalsPanel - Debug panel for signals with pattern filter
4
+ *
5
+ * Supports QuarKernel wildcard patterns:
6
+ * - auth:** → all auth signals (auth:login, auth:impersonate:start)
7
+ * - cache:** → all cache signals
8
+ * - *:created → all creation signals
9
+ * - ** → all signals (default)
10
+ */
11
+ import { ref, computed, watch } from 'vue'
12
+ import InputText from 'primevue/inputtext'
13
+ import ObjectTree from '../ObjectTree.vue'
14
+
15
+ const props = defineProps({
16
+ collector: { type: Object, required: true },
17
+ entries: { type: Array, required: true }
18
+ })
19
+
20
+ // Filter state - persisted in localStorage
21
+ const STORAGE_KEY = 'qdadm-signals-filter'
22
+ const STORAGE_KEY_MAX = 'qdadm-signals-max'
23
+ const filterPattern = ref(localStorage.getItem(STORAGE_KEY) || '')
24
+ const maxSignals = ref(parseInt(localStorage.getItem(STORAGE_KEY_MAX)) || 50)
25
+
26
+ watch(filterPattern, (val) => {
27
+ localStorage.setItem(STORAGE_KEY, val)
28
+ })
29
+
30
+ watch(maxSignals, (val) => {
31
+ localStorage.setItem(STORAGE_KEY_MAX, String(val))
32
+ })
33
+
34
+ // Convert wildcard pattern to regex
35
+ function wildcardToRegex(pattern) {
36
+ if (!pattern || pattern === '**') return null // No filter
37
+
38
+ // Escape regex special chars except * and :
39
+ let regex = pattern
40
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
41
+ // ** matches anything (including colons)
42
+ .replace(/\*\*/g, '.*')
43
+ // * matches anything except colon
44
+ .replace(/\*/g, '[^:]*')
45
+
46
+ return new RegExp(`^${regex}$`)
47
+ }
48
+
49
+ const filterRegex = computed(() => wildcardToRegex(filterPattern.value.trim()))
50
+
51
+ // Apply filter, max limit, and reverse for top-down (newest first)
52
+ const filteredEntries = computed(() => {
53
+ let result = props.entries
54
+ if (filterRegex.value) {
55
+ result = result.filter(e => filterRegex.value.test(e.name))
56
+ }
57
+ // Apply max limit (slice from end to keep newest)
58
+ if (maxSignals.value > 0 && result.length > maxSignals.value) {
59
+ result = result.slice(-maxSignals.value)
60
+ }
61
+ // Reverse for top-down display (newest first)
62
+ return [...result].reverse()
63
+ })
64
+
65
+ const filterStats = computed(() => {
66
+ const total = props.entries.length
67
+ const shown = filteredEntries.value.length
68
+ return total !== shown ? `${shown}/${total}` : `${total}`
69
+ })
70
+
71
+ // Preset filters
72
+ const presets = [
73
+ { label: 'All', pattern: '' },
74
+ { label: 'data', pattern: 'entity:data-invalidate' },
75
+ { label: 'datalayer', pattern: 'entity:datalayer-invalidate' },
76
+ { label: 'auth', pattern: 'auth:**' },
77
+ { label: 'entity', pattern: 'entity:**' },
78
+ { label: 'toast', pattern: 'toast:**' }
79
+ ]
80
+
81
+ function applyPreset(pattern) {
82
+ filterPattern.value = pattern
83
+ }
84
+
85
+ function formatTime(ts) {
86
+ const d = new Date(ts)
87
+ return d.toLocaleTimeString('en-US', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
88
+ }
89
+
90
+ // Extract domain from signal name (first segment)
91
+ function getDomain(name) {
92
+ return name.split(':')[0]
93
+ }
94
+
95
+ const domainColors = {
96
+ auth: '#10b981',
97
+ cache: '#f59e0b',
98
+ entity: '#3b82f6',
99
+ toast: '#8b5cf6',
100
+ route: '#06b6d4',
101
+ error: '#ef4444'
102
+ }
103
+
104
+ function getDomainColor(name) {
105
+ const domain = getDomain(name)
106
+ return domainColors[domain] || '#6b7280'
107
+ }
108
+ </script>
109
+
110
+ <template>
111
+ <div class="signals-panel">
112
+ <!-- Filter bar -->
113
+ <div class="signals-filter">
114
+ <div class="signals-filter-input">
115
+ <i class="pi pi-filter" />
116
+ <InputText
117
+ v-model="filterPattern"
118
+ placeholder="Filter: auth:** cache:** *:created"
119
+ class="filter-input"
120
+ />
121
+ <span class="signals-count">{{ filterStats }}</span>
122
+ <span class="signals-max-label">max</span>
123
+ <input
124
+ v-model.number="maxSignals"
125
+ type="number"
126
+ min="10"
127
+ max="500"
128
+ class="max-input"
129
+ />
130
+ </div>
131
+ <div class="signals-presets">
132
+ <button
133
+ v-for="p in presets"
134
+ :key="p.pattern"
135
+ class="preset-btn"
136
+ :class="{ 'preset-active': filterPattern === p.pattern }"
137
+ @click="applyPreset(p.pattern)"
138
+ >
139
+ {{ p.label }}
140
+ </button>
141
+ </div>
142
+ </div>
143
+
144
+ <!-- Entries list -->
145
+ <div class="signals-list">
146
+ <div v-if="filteredEntries.length === 0" class="signals-empty">
147
+ <i class="pi pi-inbox" />
148
+ <span>{{ entries.length === 0 ? 'No signals' : 'No matching signals' }}</span>
149
+ </div>
150
+
151
+ <div
152
+ v-for="(entry, idx) in filteredEntries"
153
+ :key="idx"
154
+ class="signal-entry"
155
+ :class="{ 'signal-new': entry._isNew }"
156
+ >
157
+ <div class="signal-header">
158
+ <span v-if="entry._isNew" class="signal-new-dot" title="New (unseen)" />
159
+ <span class="signal-time">{{ formatTime(entry.timestamp) }}</span>
160
+ <span class="signal-name" :style="{ color: getDomainColor(entry.name) }">
161
+ {{ entry.name }}
162
+ </span>
163
+ </div>
164
+ <div v-if="entry.data && Object.keys(entry.data).length > 0" class="signal-data">
165
+ <ObjectTree :data="entry.data" :expanded="false" />
166
+ </div>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ </template>
171
+
172
+ <style scoped>
173
+ .signals-panel {
174
+ display: flex;
175
+ flex-direction: column;
176
+ height: 100%;
177
+ }
178
+
179
+ .signals-filter {
180
+ padding: 8px 12px;
181
+ background: #27272a;
182
+ border-bottom: 1px solid #3f3f46;
183
+ display: flex;
184
+ flex-direction: column;
185
+ gap: 6px;
186
+ }
187
+
188
+ .signals-filter-input {
189
+ display: flex;
190
+ align-items: center;
191
+ gap: 8px;
192
+ }
193
+
194
+ .signals-filter-input .pi {
195
+ color: #71717a;
196
+ font-size: 12px;
197
+ }
198
+
199
+ .filter-input {
200
+ flex: 1;
201
+ font-size: 12px;
202
+ padding: 4px 8px;
203
+ background: #18181b;
204
+ border: 1px solid #3f3f46;
205
+ border-radius: 4px;
206
+ color: #f4f4f5;
207
+ }
208
+
209
+ .filter-input:focus {
210
+ border-color: #8b5cf6;
211
+ outline: none;
212
+ }
213
+
214
+ .signals-count {
215
+ font-size: 11px;
216
+ color: #71717a;
217
+ min-width: 40px;
218
+ text-align: right;
219
+ }
220
+
221
+ .signals-max-label {
222
+ font-size: 10px;
223
+ color: #52525b;
224
+ margin-left: 8px;
225
+ }
226
+
227
+ .max-input {
228
+ width: 50px;
229
+ font-size: 11px;
230
+ padding: 2px 4px;
231
+ background: #18181b;
232
+ border: 1px solid #3f3f46;
233
+ border-radius: 3px;
234
+ color: #a1a1aa;
235
+ text-align: center;
236
+ }
237
+
238
+ .max-input:focus {
239
+ border-color: #8b5cf6;
240
+ outline: none;
241
+ color: #f4f4f5;
242
+ }
243
+
244
+ .signals-presets {
245
+ display: flex;
246
+ gap: 4px;
247
+ flex-wrap: wrap;
248
+ }
249
+
250
+ .preset-btn {
251
+ padding: 2px 8px;
252
+ font-size: 10px;
253
+ background: #3f3f46;
254
+ border: none;
255
+ border-radius: 3px;
256
+ color: #a1a1aa;
257
+ cursor: pointer;
258
+ }
259
+
260
+ .preset-btn:hover {
261
+ background: #52525b;
262
+ color: #f4f4f5;
263
+ }
264
+
265
+ .preset-active {
266
+ background: #8b5cf6;
267
+ color: white;
268
+ }
269
+
270
+ .signals-list {
271
+ flex: 1;
272
+ overflow-y: auto;
273
+ padding: 4px;
274
+ }
275
+
276
+ .signals-empty {
277
+ display: flex;
278
+ flex-direction: column;
279
+ align-items: center;
280
+ justify-content: center;
281
+ height: 100%;
282
+ color: #71717a;
283
+ gap: 8px;
284
+ }
285
+
286
+ .signal-entry {
287
+ padding: 6px 8px;
288
+ margin-bottom: 2px;
289
+ background: #27272a;
290
+ border-radius: 4px;
291
+ font-size: 12px;
292
+ }
293
+
294
+ .signal-entry:hover {
295
+ background: #3f3f46;
296
+ }
297
+
298
+ .signal-entry.signal-new {
299
+ border-left: 2px solid #f59e0b;
300
+ padding-left: 6px;
301
+ }
302
+
303
+ .signal-new-dot {
304
+ display: inline-block;
305
+ width: 6px;
306
+ height: 6px;
307
+ background: #f59e0b;
308
+ border-radius: 50%;
309
+ margin-right: 4px;
310
+ flex-shrink: 0;
311
+ }
312
+
313
+ .signal-header {
314
+ display: flex;
315
+ align-items: center;
316
+ gap: 8px;
317
+ }
318
+
319
+ .signal-time {
320
+ font-size: 10px;
321
+ color: #71717a;
322
+ font-family: monospace;
323
+ }
324
+
325
+ .signal-name {
326
+ font-weight: 500;
327
+ font-family: monospace;
328
+ }
329
+
330
+ .signal-data {
331
+ margin-top: 4px;
332
+ padding-left: 12px;
333
+ font-size: 11px;
334
+ }
335
+ </style>
@@ -6,3 +6,4 @@ export { default as AuthPanel } from './AuthPanel.vue'
6
6
  export { default as EntitiesPanel } from './EntitiesPanel.vue'
7
7
  export { default as ToastsPanel } from './ToastsPanel.vue'
8
8
  export { default as EntriesPanel } from './EntriesPanel.vue'
9
+ export { default as SignalsPanel } from './SignalsPanel.vue'
@@ -59,6 +59,7 @@ export class EntityManager {
59
59
  localFilterThreshold = null, // Items threshold to switch to local filtering (null = use default)
60
60
  readOnly = false, // If true, canCreate/canUpdate/canDelete return false
61
61
  warmup = true, // If true, cache is preloaded at boot via DeferredRegistry
62
+ authSensitive = false, // If true, auto-invalidate datalayer on auth events
62
63
  // Scope control
63
64
  scopeWhitelist = null, // Array of scopes/modules that can bypass restrictions
64
65
  // Relations
@@ -85,6 +86,7 @@ export class EntityManager {
85
86
  this.localFilterThreshold = localFilterThreshold
86
87
  this._readOnly = readOnly
87
88
  this._warmup = warmup
89
+ this._authSensitive = authSensitive
88
90
 
89
91
  // Scope control
90
92
  this._scopeWhitelist = scopeWhitelist
@@ -174,6 +176,25 @@ export class EntityManager {
174
176
  this._signals.emitEntity(this.name, action, data)
175
177
  }
176
178
 
179
+ /**
180
+ * Emit entity:data-invalidate signal for client cache invalidation
181
+ *
182
+ * This is a unified signal for clients to know when entity data has changed.
183
+ * Clients can listen to `entity:data-invalidate` to refresh their views.
184
+ *
185
+ * @param {string} action - 'created', 'updated', 'deleted'
186
+ * @param {string|number} id - The affected record ID
187
+ * @private
188
+ */
189
+ _emitDataInvalidate(action, id) {
190
+ if (!this._signals) return
191
+ this._signals.emit('entity:data-invalidate', {
192
+ entity: this.name,
193
+ action,
194
+ id
195
+ })
196
+ }
197
+
177
198
  // ============ LIFECYCLE HOOKS ============
178
199
 
179
200
  /**
@@ -860,6 +881,7 @@ export class EntityManager {
860
881
  manager: this.name,
861
882
  id: result?.[this.idField]
862
883
  })
884
+ this._emitDataInvalidate('created', result?.[this.idField])
863
885
  return result
864
886
  }
865
887
  throw new Error(`[EntityManager:${this.name}] create() not implemented`)
@@ -896,6 +918,7 @@ export class EntityManager {
896
918
  manager: this.name,
897
919
  id
898
920
  })
921
+ this._emitDataInvalidate('updated', id)
899
922
  return result
900
923
  }
901
924
  throw new Error(`[EntityManager:${this.name}] update() not implemented`)
@@ -932,6 +955,7 @@ export class EntityManager {
932
955
  manager: this.name,
933
956
  id
934
957
  })
958
+ this._emitDataInvalidate('updated', id)
935
959
  return result
936
960
  }
937
961
  throw new Error(`[EntityManager:${this.name}] patch() not implemented`)
@@ -959,6 +983,7 @@ export class EntityManager {
959
983
  manager: this.name,
960
984
  id
961
985
  })
986
+ this._emitDataInvalidate('deleted', id)
962
987
  return result
963
988
  }
964
989
  throw new Error(`[EntityManager:${this.name}] delete() not implemented`)
@@ -1143,11 +1168,12 @@ export class EntityManager {
1143
1168
 
1144
1169
  const cleanups = []
1145
1170
 
1146
- // Listen for parent cache invalidation (if parents defined)
1171
+ // Listen for parent data changes (if parents defined)
1172
+ // When a parent entity's data changes, clear search cache to re-resolve parent fields
1147
1173
  if (this._parents && Object.keys(this._parents).length > 0) {
1148
1174
  const parentEntities = Object.values(this._parents).map(p => p.entity)
1149
1175
  cleanups.push(
1150
- this._signals.on('cache:entity:invalidated', ({ entity }) => {
1176
+ this._signals.on('entity:data-invalidate', ({ entity }) => {
1151
1177
  if (parentEntities.includes(entity)) {
1152
1178
  this._clearSearchCache()
1153
1179
  }
@@ -1155,12 +1181,28 @@ export class EntityManager {
1155
1181
  )
1156
1182
  }
1157
1183
 
1158
- // Listen for targeted cache invalidation (routed by EventRouter)
1159
- // EntityManager only listens to its own signal, staying simple.
1160
- // EventRouter transforms high-level events (auth:impersonate) into targeted signals.
1184
+ // Design choice: EntityManager-centric architecture
1185
+ // Storages stay simple (pure data access), EntityManager owns invalidation logic.
1186
+ //
1187
+ // Signal: entity:datalayer-invalidate { entity, actuator? }
1188
+ // - entity: target entity name, '*' for all, or null/undefined for all
1189
+ // - actuator: source of the signal ('auth' for auth events, undefined for manual)
1190
+ //
1191
+ // Routing is handled by EventRouter (configured at Kernel level):
1192
+ // 'auth:**' → { signal: 'entity:datalayer-invalidate', transform: () => ({ actuator: 'auth' }) }
1193
+ //
1194
+ // Only authSensitive entities react to actuator='auth' signals.
1195
+ // All entities react to manual signals (no actuator).
1196
+
1161
1197
  cleanups.push(
1162
- this._signals.on(`cache:entity:invalidate:${this.name}`, () => {
1163
- this.invalidateCache()
1198
+ this._signals.on('entity:datalayer-invalidate', ({ entity, actuator } = {}) => {
1199
+ // Auth-triggered: only react if authSensitive
1200
+ if (actuator === 'auth' && !this._authSensitive) return
1201
+
1202
+ // Check entity match (global or targeted)
1203
+ if (!entity || entity === '*' || entity === this.name) {
1204
+ this.invalidateDataLayer()
1205
+ }
1164
1206
  })
1165
1207
  )
1166
1208
 
@@ -1216,24 +1258,34 @@ export class EntityManager {
1216
1258
  }
1217
1259
 
1218
1260
  /**
1219
- * Invalidate the cache (call after create/update/delete)
1261
+ * Invalidate the cache, forcing next list() to refetch
1220
1262
  *
1221
- * Emits cache:entity:invalidated signal only when cache was previously valid
1222
- * to avoid duplicate signals on repeated invalidation calls.
1263
+ * Note: entity:data-invalidate signal is emitted by CRUD methods,
1264
+ * not here (to include action and id context).
1223
1265
  */
1224
1266
  invalidateCache() {
1225
- const wasValid = this._cache.valid
1226
-
1227
1267
  this._cache.valid = false
1228
1268
  this._cache.items = []
1229
1269
  this._cache.total = 0
1230
1270
  this._cache.loadedAt = null
1231
1271
  this._cacheLoading = null
1272
+ }
1273
+
1274
+ /**
1275
+ * Invalidate the entire data layer (cache + storage state)
1276
+ *
1277
+ * Called when external context changes (auth, impersonation, etc.)
1278
+ * that may affect what data the storage returns.
1279
+ *
1280
+ * - Clears EntityManager cache
1281
+ * - Calls storage.reset() if available (for storages with internal state)
1282
+ */
1283
+ invalidateDataLayer() {
1284
+ this.invalidateCache()
1232
1285
 
1233
- // Emit cache invalidation signal only when cache was actually valid
1234
- // This prevents noise on repeated invalidation calls
1235
- if (wasValid && this._signals) {
1236
- this._signals.emit('cache:entity:invalidated', { entity: this.name })
1286
+ // Reset storage if it supports it (e.g., clear auth tokens, cached responses)
1287
+ if (typeof this.storage?.reset === 'function') {
1288
+ this.storage.reset()
1237
1289
  }
1238
1290
  }
1239
1291
 
@@ -10,10 +10,13 @@
10
10
  * signals, // SignalBus instance
11
11
  * orchestrator, // Orchestrator instance (optional, for callbacks)
12
12
  * routes: {
13
- * 'auth:impersonate': [
14
- * 'cache:entity:invalidate:loans', // string = emit signal
15
- * { signal: 'notify', transform: (p) => ({ msg: p.user }) }, // object = transform
16
- * (payload, ctx) => { ... } // function = callback
13
+ * // Auth events → datalayer invalidation with actuator (only authSensitive entities react)
14
+ * 'auth:**': [{ signal: 'entity:datalayer-invalidate', transform: () => ({ actuator: 'auth' }) }],
15
+ *
16
+ * // Custom routing with transform
17
+ * 'order:completed': [
18
+ * { signal: 'notify', transform: (p) => ({ msg: `Order ${p.id} done` }) },
19
+ * (payload, ctx) => { ... } // callback
17
20
  * ]
18
21
  * }
19
22
  * })