@quazardous/qdadm 2.13.0 → 2.15.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 (50) hide show
  1. package/README.md +1 -0
  2. package/package.json +6 -4
  3. package/src/chain/index.ts +5 -0
  4. package/src/components/InfoBanner.vue +3 -3
  5. package/src/components/base/QdButton.vue +20 -0
  6. package/src/components/base/QdMessage.vue +17 -0
  7. package/src/components/dialogs/MultiStepDialog.vue +5 -5
  8. package/src/components/dialogs/SimpleDialog.vue +4 -4
  9. package/src/components/dialogs/UnsavedChangesDialog.vue +2 -2
  10. package/src/components/display/CopyableId.vue +2 -2
  11. package/src/components/edit/FormActions.vue +4 -4
  12. package/src/components/edit/FormPage.vue +7 -7
  13. package/src/components/edit/LookupPickerDialog.vue +3 -3
  14. package/src/components/editors/JsonEditorFoldable.vue +8 -8
  15. package/src/components/editors/JsonStructuredField.vue +3 -3
  16. package/src/components/editors/KeyValueEditor.vue +3 -3
  17. package/src/components/editors/LanguageEditor.vue +5 -5
  18. package/src/components/editors/ScopeEditor.vue +3 -3
  19. package/src/components/index.ts +4 -0
  20. package/src/components/layout/AppLayout.vue +2 -2
  21. package/src/components/layout/PageLayout.vue +2 -2
  22. package/src/components/layout/defaults/DefaultUserInfo.vue +2 -2
  23. package/src/components/lists/ActionButtons.vue +2 -2
  24. package/src/components/lists/ListPage.vue +4 -4
  25. package/src/components/pages/LoginPage.vue +2 -2
  26. package/src/components/show/ShowPage.vue +6 -6
  27. package/src/composables/useEntityItemShowPage.ts +8 -0
  28. package/src/composables/useListPage.ts +23 -0
  29. package/src/composables/useListPage.types.ts +3 -0
  30. package/src/composables/useLiveEntity.ts +115 -0
  31. package/src/deferred/index.ts +5 -0
  32. package/src/entity/EntityManager.cache.ts +20 -0
  33. package/src/entity/EntityManager.interface.ts +3 -0
  34. package/src/entity/EntityManager.ts +29 -1
  35. package/src/entity/EntityManager.types.ts +32 -0
  36. package/src/gen/vite-plugin.ts +4 -0
  37. package/src/index.ts +10 -0
  38. package/src/kernel/Kernel.registries.ts +31 -5
  39. package/src/kernel/Kernel.ts +3 -0
  40. package/src/kernel/Kernel.types.ts +31 -0
  41. package/src/kernel/LiveEntityRouter.ts +189 -0
  42. package/src/kernel/SSEBridge.ts +3 -0
  43. package/src/query/index.ts +7 -0
  44. package/src/security/pages/RoleList.vue +3 -3
  45. package/src/types/index.ts +2 -0
  46. package/src/utils/pluralize.ts +472 -0
  47. package/src/vite/qdadmDebugPlugin.ts +178 -2
  48. package/src/vite/qdadmPlugin.ts +5 -19
  49. package/src/vite-env.d.ts +0 -16
  50. package/src/types/pluralize.d.ts +0 -19
@@ -22,7 +22,7 @@ import { useRouter } from 'vue-router'
22
22
  import Card from 'primevue/card'
23
23
  import InputText from 'primevue/inputtext'
24
24
  import Password from 'primevue/password'
25
- import Button from 'primevue/button'
25
+ import QdButton from '../base/QdButton.vue'
26
26
  import type { OrchestratorLike } from '../../entity/EntityManager.interface'
27
27
 
28
28
  /**
@@ -245,7 +245,7 @@ async function handleLogin(): Promise<void> {
245
245
  :disabled="loading"
246
246
  />
247
247
  </div>
248
- <Button
248
+ <QdButton
249
249
  type="submit"
250
250
  :label="submitLabel"
251
251
  icon="pi pi-sign-in"
@@ -41,8 +41,8 @@ import { computed, type PropType } from 'vue'
41
41
  import PageHeader from '../layout/PageHeader.vue'
42
42
  import CardShell from '../layout/CardShell.vue'
43
43
  import { formatFetchError } from '../../utils/errors'
44
- import Button from 'primevue/button'
45
- import Message from 'primevue/message'
44
+ import QdButton from '../base/QdButton.vue'
45
+ import QdMessage from '../base/QdMessage.vue'
46
46
  import FieldGroups from '../item/FieldGroups.vue'
47
47
  import ShowField from './ShowField.vue'
48
48
  import type { ButtonSeverity } from '../../types'
@@ -187,7 +187,7 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
187
187
  <template #actions>
188
188
  <slot name="header-actions" />
189
189
  <!-- Header actions from builder (e.g., edit) -->
190
- <Button
190
+ <QdButton
191
191
  v-for="action in headerActions"
192
192
  :key="action.name"
193
193
  :label="action.label"
@@ -215,9 +215,9 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
215
215
  <!-- Error State (fetch error) -->
216
216
  <template v-else-if="fetchError">
217
217
  <slot name="error" :error="fetchError">
218
- <Message severity="error" :closable="false" class="show-error-message">
218
+ <QdMessage severity="error" :closable="false" class="show-error-message">
219
219
  {{ fetchErrorMessage }}
220
- </Message>
220
+ </QdMessage>
221
221
  </slot>
222
222
  </template>
223
223
 
@@ -269,7 +269,7 @@ type ShowFieldValue = string | number | boolean | Date | Record<string, unknown>
269
269
  <template v-if="showActions && footerActions.length > 0">
270
270
  <slot name="footer">
271
271
  <div class="show-actions">
272
- <Button
272
+ <QdButton
273
273
  v-for="action in footerActions"
274
274
  :key="action.name"
275
275
  :label="action.label"
@@ -57,6 +57,7 @@ import {
57
57
  import type { OrchestratorLike } from '../entity/EntityManager.interface'
58
58
  import type { ButtonSeverity } from '../types'
59
59
  import { useActionRegistry } from './useActionRegistry'
60
+ import { useLiveEntity } from './useLiveEntity'
60
61
 
61
62
  // #1191 — shared structural view (manager tier from useEntityItemPage)
62
63
  type Orchestrator = OrchestratorLike<EntityManager>
@@ -306,6 +307,13 @@ export function useEntityItemShowPage<T = Record<string, unknown>>(
306
307
  })
307
308
 
308
309
  const { manager, orchestrator: baseOrchestrator, data, loading, error, entityId, entityLabel, isLoaded, hydrator } = base
310
+
311
+ // Reload when THIS record changed outside the session (#1888 lot D).
312
+ // Scoped by id: an event about another record must not reload every open
313
+ // detail page. An event carrying no id concerns the whole entity and applies.
314
+ useLiveEntity(entity, manager, async () => {
315
+ await base.reload()
316
+ }, { id: () => entityId.value })
309
317
  // Cast orchestrator to include toast methods
310
318
  const orchestrator = baseOrchestrator as Orchestrator
311
319
 
@@ -34,6 +34,7 @@ import { useHooks } from './useHooks.js'
34
34
  import { useEntityItemPage, type ParentConfig, type UseEntityItemPageReturn } from './useEntityItemPage.js'
35
35
  import { useActiveStack } from '../chain/useActiveStack.js'
36
36
  import { useI18n } from '../i18n/useI18n'
37
+ import { useLiveEntity } from './useLiveEntity'
37
38
  import { formatDateOnly } from '../utils/formatters'
38
39
  import { humanizeFieldName } from '../utils/humanize'
39
40
 
@@ -1047,6 +1048,27 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1047
1048
  window.removeEventListener('resize', handleResize)
1048
1049
  })
1049
1050
 
1051
+ // Reload when this entity changed outside the session (#1888 lot D).
1052
+ //
1053
+ // Only fires for entities the app declared in sse.entities, so an app with
1054
+ // no live backend is untouched. The reload preserves selection: a list
1055
+ // refreshing under someone mid-bulk-action must not silently empty their
1056
+ // checkboxes — the rows are re-matched by key, and anything that vanished
1057
+ // server-side simply drops out.
1058
+ useLiveEntity(entity, manager, async () => {
1059
+ const keptKeys = new Set(
1060
+ selected.value.map((item) => (item as Record<string, unknown>)[resolvedDataKey])
1061
+ )
1062
+
1063
+ await loadItems({}, { force: true })
1064
+
1065
+ if (keptKeys.size) {
1066
+ selected.value = items.value.filter((item) =>
1067
+ keptKeys.has((item as Record<string, unknown>)[resolvedDataKey])
1068
+ ) as T[]
1069
+ }
1070
+ })
1071
+
1050
1072
  // ============ UTILITIES ============
1051
1073
  // Browser-locale by default (unified policy — see utils/formatters).
1052
1074
  function formatDate(
@@ -1112,6 +1134,7 @@ export function useListPage<T = unknown>(config: UseListPageOptions<T>): UseList
1112
1134
 
1113
1135
  const listProps = computed<ListPageProps>(() => ({
1114
1136
  title: manager.labelPlural,
1137
+ subtitle: config.subtitle !== undefined ? config.subtitle : (manager.description ?? null),
1115
1138
  headerActions: headerActions.value,
1116
1139
  cards: cards.value,
1117
1140
  columns: columns.value,
@@ -247,6 +247,8 @@ export interface UseListPageOptions<T = unknown> {
247
247
  entityName?: string
248
248
  /** Override manager.labelPlural */
249
249
  entityNamePlural?: string
250
+ /** Override manager.description (list page subtitle). Pass null to suppress it. */
251
+ subtitle?: string | null
250
252
  /** Override manager.routePrefix */
251
253
  routePrefix?: string
252
254
  }
@@ -256,6 +258,7 @@ export interface UseListPageOptions<T = unknown> {
256
258
  */
257
259
  export interface ListPageProps {
258
260
  title: string | undefined
261
+ subtitle: string | null
259
262
  headerActions: ResolvedHeaderAction[]
260
263
  cards: CardConfig[]
261
264
  columns: ColumnConfig[]
@@ -0,0 +1,115 @@
1
+ /**
2
+ * useLiveEntity — reload a mounted screen when its entity changed elsewhere
3
+ * (#1888 lot D, reported in #1887).
4
+ *
5
+ * Invalidating marks a cache stale; it does not repaint anything. A list that
6
+ * is *already on screen* keeps its rows until the user navigates away and back
7
+ * — which is precisely the case a pushing backend exists for: someone is
8
+ * looking at the screen right now.
9
+ *
10
+ * There is no page-level opt-in, deliberately. The gate is upstream: only an
11
+ * entity the app declared in `sse.entities` ever produces a remote event, so an
12
+ * app with no live backend sees no behaviour change and has no flag to forget.
13
+ * What the entity does about it is its own policy (`live` on the manager).
14
+ *
15
+ * @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
16
+ */
17
+
18
+ import { inject, onUnmounted } from 'vue'
19
+ import type { SignalBus } from '../kernel/SignalBus'
20
+
21
+ export interface UseLiveEntityOptions {
22
+ /**
23
+ * Only react to events carrying this record id. Detail screens pass their
24
+ * own id: without it, changing one record reloads every open detail page.
25
+ */
26
+ id?: () => string | number | null | undefined
27
+ }
28
+
29
+ interface LivePolicy {
30
+ refresh?: 'mounted' | false
31
+ coalesceMs?: number
32
+ }
33
+
34
+
35
+ /**
36
+ * Subscribe the calling component to remote changes of `entityName`.
37
+ *
38
+ * @param entityName - entity to watch
39
+ * @param manager - its manager, read for the `live` policy (may be null)
40
+ * @param reload - what to run; coalesced, never called concurrently by us
41
+ * @returns unsubscribe function (also called automatically on unmount)
42
+ */
43
+ export function useLiveEntity(
44
+ entityName: string,
45
+ // Taken as unknown and read defensively: the page composables carry
46
+ // structural manager views (EntityManagerRead) that predate this policy.
47
+ manager: unknown,
48
+ reload: () => void | Promise<void>,
49
+ options: UseLiveEntityOptions = {}
50
+ ): () => void {
51
+ const signals = inject<SignalBus | null>('qdadmSignals', null)
52
+ if (!signals || !entityName) return () => {}
53
+
54
+ const policy = (manager as { live?: LivePolicy } | null | undefined)?.live ?? {}
55
+ // `refresh: false` — invalidation still happened upstream, the screen just
56
+ // does not chase it. Subscribing would only burn a listener.
57
+ if (policy.refresh === false) return () => {}
58
+
59
+ const coalesceMs = policy.coalesceMs ?? 300
60
+ let timer: ReturnType<typeof setTimeout> | null = null
61
+ let disposed = false
62
+
63
+ const fire = (): void => {
64
+ timer = null
65
+ if (disposed) return
66
+ void reload()
67
+ }
68
+
69
+ const off = signals.on('entity:data-invalidate', (event: { name: string; data: unknown }) => {
70
+ const payload = (event.data || {}) as {
71
+ entity?: string
72
+ id?: string | number
73
+ source?: string
74
+ }
75
+
76
+ // Local writes are the manager's own echo: it has already repaired its
77
+ // cache and the page that triggered the write reloads on its own path.
78
+ if (payload.source !== 'remote') return
79
+ if (payload.entity !== entityName) return
80
+
81
+ if (options.id) {
82
+ const watched = options.id()
83
+ // An event carrying no id concerns the whole entity, so it always
84
+ // applies; one carrying a different id is somebody else's record.
85
+ if (
86
+ watched != null &&
87
+ payload.id !== undefined &&
88
+ String(payload.id) !== String(watched)
89
+ ) {
90
+ return
91
+ }
92
+ }
93
+
94
+ // Coalesce: a backend replaying fifty rows must cost one reload, not fifty.
95
+ if (coalesceMs <= 0) {
96
+ fire()
97
+ return
98
+ }
99
+ if (timer) return
100
+ timer = setTimeout(fire, coalesceMs)
101
+ })
102
+
103
+ const stop = (): void => {
104
+ disposed = true
105
+ if (timer) {
106
+ clearTimeout(timer)
107
+ timer = null
108
+ }
109
+ off()
110
+ }
111
+
112
+ onUnmounted(stop)
113
+
114
+ return stop
115
+ }
@@ -2,6 +2,11 @@
2
2
  * Deferred Module
3
3
  *
4
4
  * Named promise registry for loose async coupling between services and components.
5
+ *
6
+ * @experimental Shape may change in a minor release — see
7
+ * docs/API_STABILITY.md.
8
+ *
9
+ * @module deferred
5
10
  */
6
11
 
7
12
  export {
@@ -332,6 +332,26 @@ export function applyCacheMethods(EntityManagerClass: { prototype: any }): void
332
332
  )
333
333
  }
334
334
 
335
+ // Listen for REMOTE changes to this entity — a mutation nobody here made.
336
+ //
337
+ // The invalidation signals were written for local mutations, where the
338
+ // manager that mutates repairs its own cache on the way out. A change that
339
+ // happened elsewhere repairs nothing, so the list cache would stay stale
340
+ // while claiming to be valid (#1887). Gated on the origin marker: without
341
+ // it, this handler would fire on every local write too and force a refetch
342
+ // of a list the manager is already holding correctly.
343
+ cleanups.push(
344
+ signals.on('entity:data-invalidate', (event: { name: string; data: unknown }) => {
345
+ const { entity, source } = (event.data || {}) as {
346
+ entity?: string
347
+ source?: string
348
+ }
349
+ if (source !== 'remote') return
350
+ if (entity !== this.name) return
351
+ this.invalidateCache()
352
+ })
353
+ )
354
+
335
355
  // Listen for own entity data changes to invalidate detail cache
336
356
  if (this.isAsymmetric && this.isDetailCacheEnabled) {
337
357
  cleanups.push(
@@ -37,6 +37,8 @@ export interface EntityManagerBase<T = unknown> {
37
37
  readonly idField: string
38
38
  label?: string
39
39
  labelPlural?: string
40
+ /** Optional one-liner explaining what the entity is (list page subtitle) */
41
+ description?: string | null
40
42
  routePrefix?: string
41
43
  labelField?: string | ((entity: T) => string)
42
44
  readOnly?: boolean
@@ -137,6 +139,7 @@ export interface EntityManagerLike {
137
139
  idField: string
138
140
  label?: string
139
141
  labelPlural?: string
142
+ description?: string | null
140
143
  // Function-typed PROPERTY → strictly contravariant param; `never` is the
141
144
  // contravariant top, so the canonical `(entity: T) => string` assigns.
142
145
  // Cast at the call site when invoking.
@@ -1,7 +1,7 @@
1
1
  import { PermissiveAuthAdapter } from './auth/PermissiveAdapter'
2
2
  import { AuthActions, type AuthUser } from './auth/EntityAuthAdapter'
3
3
  import type { EntityAuthAdapter as IEntityAuthAdapter } from './auth/EntityAuthAdapter'
4
- import pluralize from 'pluralize'
4
+ import pluralize from '../utils/pluralize'
5
5
  import { applyCacheMethods } from './EntityManager.cache'
6
6
  import { applyQueryMethods } from './EntityManager.query'
7
7
  import { applyRelationsMethods } from './EntityManager.relations'
@@ -63,6 +63,7 @@ import type {
63
63
  SeverityMap,
64
64
  SeverityDescriptor,
65
65
  EntityManagerOptions,
66
+ LiveEntityPolicy,
66
67
  } from './EntityManager.types'
67
68
 
68
69
  /**
@@ -82,6 +83,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
82
83
  protected _badges: ((entity: T) => EntityBadge[]) | null
83
84
  protected _label: string | null
84
85
  protected _labelPlural: string | null
86
+ protected _description: string | null
85
87
  protected _routePrefix: string | null
86
88
  protected _fields: Record<string, FieldConfig>
87
89
 
@@ -97,6 +99,8 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
97
99
  protected _readOnly: boolean
98
100
  protected _warmup: boolean
99
101
  protected _authSensitive: boolean
102
+ /** Reaction policy for changes made outside this session (#1888). */
103
+ protected _live: Required<LiveEntityPolicy>
100
104
  protected _system: boolean
101
105
 
102
106
  protected _scopeWhitelist: string[] | null
@@ -153,6 +157,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
153
157
  idField = 'id',
154
158
  label = null,
155
159
  labelPlural = null,
160
+ description = null,
156
161
  routePrefix = null,
157
162
  labelField = 'name',
158
163
  badges = null,
@@ -166,6 +171,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
166
171
  readOnly = false,
167
172
  warmup = true,
168
173
  authSensitive,
174
+ live,
169
175
  system = false,
170
176
  scopeWhitelist = null,
171
177
  isOwn = null,
@@ -185,6 +191,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
185
191
 
186
192
  this._label = label
187
193
  this._labelPlural = labelPlural
194
+ this._description = description
188
195
  this._routePrefix = routePrefix
189
196
  this._fields = fields
190
197
 
@@ -197,6 +204,11 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
197
204
  this._readOnly = readOnly
198
205
  this._warmup = warmup
199
206
  this._authSensitive = authSensitive ?? this._getStorageRequiresAuth()
207
+ // Pre-wired defaults: the common case declares nothing.
208
+ this._live = {
209
+ refresh: live?.refresh ?? 'mounted',
210
+ coalesceMs: live?.coalesceMs ?? 300,
211
+ }
200
212
  this._system = system
201
213
 
202
214
  this._scopeWhitelist = scopeWhitelist
@@ -258,6 +270,10 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
258
270
  entity: this.name,
259
271
  action,
260
272
  id,
273
+ // This manager just performed the mutation and has already repaired its
274
+ // own cache — marking the origin lets it ignore its own echo instead of
275
+ // refetching a list it is already holding correctly (#1888 lot B).
276
+ source: 'local',
261
277
  })
262
278
  }
263
279
 
@@ -415,6 +431,13 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
415
431
  return plural.charAt(0).toUpperCase() + plural.slice(1)
416
432
  }
417
433
 
434
+ /**
435
+ * Optional one-liner explaining what the entity is (list page subtitle)
436
+ */
437
+ get description(): string | null {
438
+ return this._description
439
+ }
440
+
418
441
  /**
419
442
  * Get route prefix for this entity
420
443
  */
@@ -554,6 +577,11 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
554
577
  /**
555
578
  * Check if user can read entities
556
579
  */
580
+ /** Reaction policy for changes made outside this session (#1888). */
581
+ get live(): Required<LiveEntityPolicy> {
582
+ return this._live
583
+ }
584
+
557
585
  canRead(entity: T | null = null): boolean {
558
586
  return this.canAccess(AuthActions.READ, entity)
559
587
  }
@@ -200,6 +200,24 @@ export type SeverityMapValue = string | SeverityDescriptor
200
200
  /** Severity map: field value → severity string or descriptor */
201
201
  export type SeverityMap = Record<string, SeverityMapValue>
202
202
 
203
+ /**
204
+ * Policy for reacting to a change that happened outside this session.
205
+ *
206
+ * @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
207
+ */
208
+ export interface LiveEntityPolicy {
209
+ /**
210
+ * `'mounted'` (default) — a screen currently showing this entity reloads.
211
+ * `false` — invalidate the cache only; the screen updates next time it asks.
212
+ */
213
+ refresh?: 'mounted' | false
214
+ /**
215
+ * Window (ms) over which a burst of events collapses into a single reload.
216
+ * Default 300. `0` reloads on every event — rarely what you want.
217
+ */
218
+ coalesceMs?: number
219
+ }
220
+
203
221
  /**
204
222
  * EntityManager constructor options
205
223
  */
@@ -209,6 +227,8 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
209
227
  idField?: string
210
228
  label?: string
211
229
  labelPlural?: string
230
+ /** Optional one-liner explaining what the entity is — shown under the list page title */
231
+ description?: string
212
232
  routePrefix?: string
213
233
  labelField?: string | ((entity: T) => string)
214
234
  /** Callback to compute badges for an entity item header */
@@ -228,6 +248,18 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
228
248
  readOnly?: boolean
229
249
  warmup?: boolean
230
250
  authSensitive?: boolean
251
+ /**
252
+ * What this entity does when it learns it changed elsewhere (#1888).
253
+ *
254
+ * The kernel's `sse.entities` declares *that* an entity has an external
255
+ * writer — a deployment property. This is *what the entity does about it*,
256
+ * which is behaviour and belongs here. Pre-wired so the common case needs
257
+ * nothing: mounted screens refresh, bursts coalesce over 300 ms.
258
+ *
259
+ * Override per entity — a heavy `logs` list may want `refresh: false`, i.e.
260
+ * drop the stale cache but leave the screen alone until the user asks.
261
+ */
262
+ live?: LiveEntityPolicy
231
263
  system?: boolean
232
264
  scopeWhitelist?: string[] | null
233
265
  isOwn?: ((record: T, user: AuthUser) => boolean) | null
@@ -4,6 +4,10 @@
4
4
  * Integrates generateManagers with Vite's build pipeline, triggering
5
5
  * entity manager file generation during the buildStart hook.
6
6
  *
7
+ * @experimental Undocumented build-time entry. Prefer the programmatic
8
+ * `generateManagers` entry (`@quazardous/qdadm/gen`), which is stable — see
9
+ * docs/API_STABILITY.md.
10
+ *
7
11
  * @module gen/vite-plugin
8
12
  */
9
13
 
package/src/index.ts CHANGED
@@ -39,6 +39,16 @@ export type {
39
39
  } from './kernel/EventRouter'
40
40
  export { SSEBridge, createSSEBridge, SSE_SIGNALS } from './kernel/SSEBridge'
41
41
  export type { SSEBridgeOptions } from './kernel/SSEBridge'
42
+ export { useLiveEntity } from './composables/useLiveEntity'
43
+ export type { UseLiveEntityOptions } from './composables/useLiveEntity'
44
+ export type { LiveEntityPolicy } from './entity/EntityManager.types'
45
+ export { LiveEntityRouter, createLiveEntityRouter } from './kernel/LiveEntityRouter'
46
+ export type {
47
+ LiveEntityRouterOptions,
48
+ LiveEntityAction,
49
+ LiveEntitiesDeclaration,
50
+ LiveOrchestratorLike,
51
+ } from './kernel/LiveEntityRouter'
42
52
  export { Module } from './kernel/Module'
43
53
  export { KernelContext, createKernelContext } from './kernel/KernelContext'
44
54
  export type {
@@ -9,6 +9,7 @@ import { defaultStorageResolver } from '../entity/storage/factory'
9
9
  import { createDeferredRegistry } from '../deferred/DeferredRegistry.js'
10
10
  import { createEventRouter } from './EventRouter'
11
11
  import { createSSEBridge } from './SSEBridge'
12
+ import { createLiveEntityRouter, type LiveOrchestratorLike } from './LiveEntityRouter'
12
13
  import { ActiveStack } from '../chain/ActiveStack.js'
13
14
  import { StackHydrator } from '../chain/StackHydrator.js'
14
15
  import { Orchestrator } from '../orchestrator/Orchestrator'
@@ -222,9 +223,14 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
222
223
 
223
224
  const debug = this.options.debug ?? false
224
225
 
225
- const getToken = authAdapter?.getToken
226
- ? () => authAdapter.getToken!()
227
- : () => localStorage.getItem('auth_token')
226
+ // An explicit sse.getToken wins — including an explicit null, which means
227
+ // "send no token" and must not fall back to the auth adapter.
228
+ const getToken =
229
+ sse.getToken !== undefined
230
+ ? sse.getToken
231
+ : authAdapter?.getToken
232
+ ? () => authAdapter.getToken!()
233
+ : () => localStorage.getItem('auth_token')
228
234
 
229
235
  this.sseBridge = createSSEBridge({
230
236
  signals: this.signals!,
@@ -235,13 +241,33 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
235
241
  withCredentials: sse.withCredentials ?? false,
236
242
  tokenParam: sse.tokenParam ?? 'token',
237
243
  getToken,
244
+ ...(sse.connectOnSignal !== undefined ? { connectOnSignal: sse.connectOnSignal } : {}),
245
+ ...(sse.disconnectOnSignal !== undefined ? { disconnectOnSignal: sse.disconnectOnSignal } : {}),
238
246
  debug,
239
247
  })
240
248
 
241
- if (sse.events?.length) {
249
+ // The declared entities imply their own frames: an app that declares
250
+ // `entities` should not also have to remember to list the event names.
251
+ const declaredEvents = sse.entities
252
+ ? ['entity:created', 'entity:updated', 'entity:deleted']
253
+ : []
254
+ const events = [...new Set([...(sse.events ?? []), ...declaredEvents])]
255
+
256
+ if (events.length) {
242
257
  this.signals!.once('sse:connected', () => {
243
- this.sseBridge!.registerEvents(sse.events!)
258
+ this.sseBridge!.registerEvents(events)
259
+ })
260
+ }
261
+
262
+ if (sse.entities) {
263
+ this.liveEntityRouter = createLiveEntityRouter({
264
+ signals: this.signals!,
265
+ entities: sse.entities,
266
+ orchestrator: this.orchestrator as unknown as LiveOrchestratorLike,
267
+ signalPrefix: sse.signalPrefix ?? 'sse',
268
+ debug,
244
269
  })
270
+ this.liveEntityRouter.attachSignalTransport()
245
271
  }
246
272
  }
247
273
  }
@@ -39,6 +39,7 @@ import type { KernelContext } from './KernelContext'
39
39
  import type { DeferredRegistry } from '../deferred/DeferredRegistry.js'
40
40
  import type { EventRouter } from './EventRouter'
41
41
  import type { SSEBridge } from './SSEBridge'
42
+ import type { LiveEntityRouter } from './LiveEntityRouter'
42
43
  import type { ActiveStack } from '../chain/ActiveStack.js'
43
44
  import type { StackHydrator } from '../chain/StackHydrator.js'
44
45
  import { NotificationModule } from '../notifications/NotificationModule'
@@ -89,6 +90,8 @@ export class Kernel {
89
90
  deferred: DeferredRegistry | null = null
90
91
  eventRouter: EventRouter | null = null
91
92
  sseBridge: SSEBridge | null = null
93
+ /** Routes external entity changes to cache invalidation (#1888). */
94
+ liveEntityRouter: LiveEntityRouter | null = null
92
95
  layoutComponents: InternalLayoutComponents | null = null
93
96
  securityChecker: SecurityChecker | null = null
94
97
  permissionRegistry: PermissionRegistry | null = null
@@ -109,6 +109,37 @@ export interface SSEConfig {
109
109
  withCredentials?: boolean
110
110
  tokenParam?: string
111
111
  events?: string[]
112
+ /**
113
+ * Entities this backend writes to out of session.
114
+ *
115
+ * The APP declares what it knows about the backend — the backend knows
116
+ * nothing of this config, it only emits. Declaring an entity here routes the
117
+ * stream's `entity:{created,updated,deleted}` frames to that entity's cache
118
+ * invalidation; anything not declared is ignored (a stream carries more than
119
+ * entity mutations). `true` or `'*'` accepts every registered entity.
120
+ *
121
+ * See docs/adr/0009-live-entities.md.
122
+ */
123
+ entities?: string[] | true | '*'
124
+ /**
125
+ * Token appended to the SSE URL as a query parameter.
126
+ *
127
+ * Defaults to the session auth adapter's token. Override it — the function
128
+ * may be async — when that durable credential must not reach an access log:
129
+ * return a short-lived, single-use ticket fetched per connect instead. Pass
130
+ * `null` to send no token at all (e.g. cookie auth with `withCredentials`).
131
+ */
132
+ getToken?: (() => string | null | Promise<string | null>) | null
133
+ /**
134
+ * Signal that triggers a connect. Defaults to `'auth:login'`;
135
+ * `null` disables the auth coupling.
136
+ */
137
+ connectOnSignal?: string | null
138
+ /**
139
+ * Signal that triggers a disconnect. Defaults to `'auth:logout'`;
140
+ * `null` disables the auth coupling.
141
+ */
142
+ disconnectOnSignal?: string | null
112
143
  }
113
144
 
114
145
  /**