@quazardous/qdadm 2.14.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.
- package/README.md +1 -0
- package/package.json +6 -4
- package/src/chain/index.ts +5 -0
- package/src/composables/useEntityItemShowPage.ts +8 -0
- package/src/composables/useListPage.ts +23 -0
- package/src/composables/useListPage.types.ts +3 -0
- package/src/composables/useLiveEntity.ts +115 -0
- package/src/deferred/index.ts +5 -0
- package/src/entity/EntityManager.cache.ts +20 -0
- package/src/entity/EntityManager.interface.ts +3 -0
- package/src/entity/EntityManager.ts +29 -1
- package/src/entity/EntityManager.types.ts +32 -0
- package/src/gen/vite-plugin.ts +4 -0
- package/src/index.ts +10 -0
- package/src/kernel/Kernel.registries.ts +31 -5
- package/src/kernel/Kernel.ts +3 -0
- package/src/kernel/Kernel.types.ts +31 -0
- package/src/kernel/LiveEntityRouter.ts +189 -0
- package/src/kernel/SSEBridge.ts +3 -0
- package/src/query/index.ts +7 -0
- package/src/types/index.ts +2 -0
- package/src/utils/pluralize.ts +472 -0
- package/src/vite/qdadmPlugin.ts +5 -19
- package/src/vite-env.d.ts +0 -16
- package/src/generated/managers/testManager.js +0 -45
- package/src/types/pluralize.d.ts +0 -19
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quazardous/qdadm",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.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.
|
|
55
|
-
"@types/pluralize": "^0.0.33",
|
|
54
|
+
"@quazardous/qdcore": "^1.1.0",
|
|
56
55
|
"@quazardous/qddebug": "^1.0.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": {
|
package/src/chain/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/deferred/index.ts
CHANGED
|
@@ -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
|
package/src/gen/vite-plugin.ts
CHANGED
|
@@ -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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/src/kernel/Kernel.ts
CHANGED
|
@@ -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
|
/**
|