@quazardous/qdadm 2.14.0 → 2.16.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/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  ```bash
8
8
  npm install @quazardous/qdadm primevue @primeuix/themes primeicons vue-router pinia
9
+ npm install -D sass-embedded # compiles qdadm's shipped .scss styles (or `sass`)
9
10
  ```
10
11
 
11
12
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quazardous/qdadm",
3
- "version": "2.14.0",
3
+ "version": "2.16.0",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -51,16 +51,15 @@
51
51
  "LICENSE"
52
52
  ],
53
53
  "dependencies": {
54
- "@quazardous/qdcore": "^1.0.0",
55
- "@types/pluralize": "^0.0.33",
56
- "@quazardous/qddebug": "^1.0.0",
54
+ "@quazardous/qdcore": "^1.1.0",
55
+ "@quazardous/qddebug": "^1.1.0",
57
56
  "@quazardous/quarkernel": "^2.1.0",
58
- "pluralize": "^8.0.0",
59
57
  "yaml": "^2.8.4"
60
58
  },
61
59
  "peerDependencies": {
62
60
  "pinia": "^2.0.0 || ^3.0.0 || ^4.0.0",
63
61
  "primevue": "^4.0.0",
62
+ "sass-embedded": "^1.0.0",
64
63
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
65
64
  "vue": "^3.3.0",
66
65
  "vue-router": "^4.0.0 || ^5.0.0"
@@ -74,6 +73,9 @@
74
73
  },
75
74
  "primevue": {
76
75
  "optional": true
76
+ },
77
+ "sass-embedded": {
78
+ "optional": "true"
77
79
  }
78
80
  },
79
81
  "optionalDependencies": {
@@ -7,6 +7,11 @@
7
7
  * - useActiveStack: Composable to build and access the sync stack
8
8
  * - useStackHydrator: Composable to access hydrated data
9
9
  *
10
+ * @experimental Navigation-stack plumbing that happens to be exported: the
11
+ * level shape and the hydration payloads may change in a minor release. The
12
+ * parent/child routing built on top of it is stable — see
13
+ * docs/API_STABILITY.md.
14
+ *
10
15
  * @module chain
11
16
  */
12
17
 
@@ -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/i18n/I18n.ts CHANGED
@@ -70,6 +70,8 @@ export class I18n {
70
70
  // Concurrent domain loads — share one in-flight promise per (locale, domain)
71
71
  // pair across providers and t() callers.
72
72
  private _inflightDomains: Map<string, Promise<void>> = new Map()
73
+ // (locale::key) pairs already announced on the bus — see onMissing.
74
+ private _missingReported: Set<string> = new Set()
73
75
 
74
76
  constructor(options: I18nOptions = {}, deps: I18nDeps = {}) {
75
77
  this._defaultLocale = options.defaultLocale ?? 'en'
@@ -101,7 +103,16 @@ export class I18n {
101
103
  fallbackLocale: this._fallbackLocale,
102
104
  globalAliases: this._currentStrategyAliases(),
103
105
  onMissing: (key, locale) => {
104
- if (this._emitMissing) this._signals?.emit('i18n:missing', { key, locale })
106
+ if (!this._emitMissing) return
107
+ // A missing key is a FACT, not a stream: knowing it once is the whole
108
+ // diagnostic value. Emitting on every resolution turned a signal into
109
+ // a firehose — the same seven nav keys fired 1616 times in two seconds
110
+ // downstream, and each emission was recorded by two collectors, which
111
+ // fed the tick that triggered the next resolution (#1896).
112
+ const seenKey = `${locale}::${key}`
113
+ if (this._missingReported.has(seenKey)) return
114
+ this._missingReported.add(seenKey)
115
+ this._signals?.emit('i18n:missing', { key, locale })
105
116
  },
106
117
  })
107
118
 
@@ -184,6 +195,7 @@ export class I18n {
184
195
  if (this.locale.value === locale && this._loadedLocales.has(locale)) return
185
196
  await this._loadLocale(locale)
186
197
  this.locale.value = locale
198
+ this._resetMissingReports()
187
199
  this._signals?.emit('locale:changed', locale)
188
200
  }
189
201
 
@@ -247,6 +259,17 @@ export class I18n {
247
259
  // Internals
248
260
  // ---------------------------------------------------------------------------
249
261
 
262
+ /**
263
+ * Forget which missing keys were already announced.
264
+ *
265
+ * Called whenever new messages arrive or the locale changes: a key that was
266
+ * missing may now resolve, and one that goes missing again afterwards is
267
+ * news worth reporting once more.
268
+ */
269
+ private _resetMissingReports(): void {
270
+ this._missingReported.clear()
271
+ }
272
+
250
273
  private async _loadLocale(locale: string): Promise<void> {
251
274
  for (const p of this._providers) {
252
275
  const bundle = await p.load(locale)
@@ -300,6 +323,7 @@ export class I18n {
300
323
  this._loadedDomains.add(cacheKey)
301
324
  this._inflightDomains.delete(cacheKey)
302
325
  if (merged) {
326
+ this._resetMissingReports()
303
327
  this._signals?.emit('i18n:domain-loaded', { locale, domain })
304
328
  }
305
329
  })()
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A missing key is announced once, not on every resolution (#1896).
3
+ *
4
+ * Downstream, seven nav labels fired 1616 `i18n:missing` each in two seconds:
5
+ * every render resolved them, every resolution emitted, and each emission was
6
+ * recorded by two collectors that bumped the tick driving the next render.
7
+ * Capping the signal removes the fuel — the diagnostic value of a missing key
8
+ * is knowing it exists, which takes exactly one signal.
9
+ */
10
+ import { describe, it, expect, beforeEach } from 'vitest'
11
+ import { I18n } from '../I18n'
12
+
13
+ function makeI18n(options = {}) {
14
+ const emitted: Array<{ signal: string; payload: unknown }> = []
15
+ const signals = {
16
+ emit: (signal: string, payload?: unknown) => {
17
+ emitted.push({ signal, payload })
18
+ },
19
+ on: () => () => {},
20
+ }
21
+ const i18n = new I18n({ disableDefaultCoreBundle: true, ...options }, { signals })
22
+ const missing = () => emitted.filter((e) => e.signal === 'i18n:missing')
23
+ return { i18n, emitted, missing }
24
+ }
25
+
26
+ describe('I18n — missing keys are announced once', () => {
27
+ it('emits once however many times the key is resolved', () => {
28
+ const { i18n, missing } = makeI18n()
29
+
30
+ for (let i = 0; i < 200; i++) i18n.t('nav.sections.Control')
31
+
32
+ const announced = missing()
33
+ expect(announced).toHaveLength(1)
34
+ expect((announced[0]!.payload as { key: string }).key).toBe('nav.sections.Control')
35
+ })
36
+
37
+ it('still announces each distinct key', () => {
38
+ const { i18n, missing } = makeI18n()
39
+
40
+ for (let i = 0; i < 50; i++) {
41
+ i18n.t('nav.routes.run')
42
+ i18n.t('nav.routes.job')
43
+ i18n.t('breadcrumb.details')
44
+ }
45
+
46
+ expect(missing()).toHaveLength(3)
47
+ expect(missing().map((m) => (m.payload as { key: string }).key).sort()).toEqual([
48
+ 'breadcrumb.details',
49
+ 'nav.routes.job',
50
+ 'nav.routes.run',
51
+ ])
52
+ })
53
+
54
+ it('keeps returning the fallback — capping the signal changes no output', () => {
55
+ const { i18n } = makeI18n()
56
+
57
+ const first = i18n.t('nav.routes.run')
58
+ const later = i18n.t('nav.routes.run')
59
+
60
+ expect(later).toBe(first)
61
+ })
62
+
63
+ it('honours emitMissing: false', () => {
64
+ const { i18n, missing } = makeI18n({ emitMissing: false })
65
+
66
+ i18n.t('nav.routes.run')
67
+
68
+ expect(missing()).toHaveLength(0)
69
+ })
70
+
71
+ it('announces again after the locale changes — the fact may no longer hold', async () => {
72
+ const { i18n, missing } = makeI18n()
73
+
74
+ i18n.t('nav.routes.run')
75
+ expect(missing()).toHaveLength(1)
76
+
77
+ await i18n.changeLocale('fr')
78
+ i18n.t('nav.routes.run')
79
+
80
+ expect(missing()).toHaveLength(2)
81
+ })
82
+ })
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 {