@quazardous/qdadm 2.26.1 → 2.27.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 +1 -1
- package/src/composables/useEntityItemFormPage.ts +10 -1
- package/src/composables/useEntityItemPage.ts +11 -1
- package/src/composables/useLiveEntity.ts +125 -3
- package/src/kernel/Kernel.registries.ts +14 -1
- package/src/kernel/Kernel.ts +3 -0
- package/src/kernel/Kernel.types.ts +17 -0
- package/src/kernel/Kernel.vue.ts +2 -0
- package/src/kernel/sseSubscriptions.ts +54 -0
package/package.json
CHANGED
|
@@ -273,7 +273,14 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
|
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
// Only the latest load may write (#2666): moving quickly between records, a
|
|
277
|
+
// slow answer for the previous one would otherwise land in this record's form.
|
|
278
|
+
let loadSeq = 0
|
|
279
|
+
|
|
276
280
|
async function load(): Promise<void> {
|
|
281
|
+
const seq = ++loadSeq
|
|
282
|
+
const isLatest = (): boolean => seq === loadSeq
|
|
283
|
+
|
|
277
284
|
if (!isEdit.value) {
|
|
278
285
|
data.value = deepClone(initialData) as T
|
|
279
286
|
takeSnapshot()
|
|
@@ -285,6 +292,7 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
|
|
|
285
292
|
// The item itself, never a cached list row: a list row may be a summary,
|
|
286
293
|
// and whatever this form shows empty it saves back empty (#2484).
|
|
287
294
|
const responseData = await manager.get(entityId.value!, undefined, { listCache: false })
|
|
295
|
+
if (!isLatest()) return
|
|
288
296
|
const transformed = transformLoad(responseData)
|
|
289
297
|
if (debug) reportMissingFields(transformed as Record<string, unknown>)
|
|
290
298
|
data.value = transformed as T
|
|
@@ -298,6 +306,7 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
|
|
|
298
306
|
await onLoadSuccess(transformed as T)
|
|
299
307
|
}
|
|
300
308
|
} catch (error) {
|
|
309
|
+
if (!isLatest()) return
|
|
301
310
|
const axiosError = error as AxiosError
|
|
302
311
|
toast.add({
|
|
303
312
|
severity: 'error',
|
|
@@ -306,7 +315,7 @@ export function useEntityItemFormPage<T extends Record<string, unknown> = Record
|
|
|
306
315
|
life: 5000,
|
|
307
316
|
})
|
|
308
317
|
} finally {
|
|
309
|
-
loading.value = false
|
|
318
|
+
if (isLatest()) loading.value = false
|
|
310
319
|
}
|
|
311
320
|
}
|
|
312
321
|
|
|
@@ -315,7 +315,15 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
|
|
|
315
315
|
* @param id - Optional ID override (defaults to route param)
|
|
316
316
|
* @returns Loaded entity data or null on error
|
|
317
317
|
*/
|
|
318
|
+
let loadSeq = 0
|
|
319
|
+
|
|
318
320
|
async function load(id: string | number | null = entityId.value): Promise<T | null> {
|
|
321
|
+
// Only the latest call may write (#2666). Live reloads and id changes can
|
|
322
|
+
// overlap; an older answer landing last would put a past state on screen.
|
|
323
|
+
// A superseded call resolves to null and touches nothing.
|
|
324
|
+
const seq = ++loadSeq
|
|
325
|
+
const isLatest = (): boolean => seq === loadSeq
|
|
326
|
+
|
|
319
327
|
if (!id) {
|
|
320
328
|
error.value = 'No entity ID provided'
|
|
321
329
|
return null
|
|
@@ -326,6 +334,7 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
|
|
|
326
334
|
|
|
327
335
|
try {
|
|
328
336
|
const responseData = await manager.get(id)
|
|
337
|
+
if (!isLatest()) return null
|
|
329
338
|
|
|
330
339
|
if (!responseData) {
|
|
331
340
|
error.value = `${manager.label || entity} not found`
|
|
@@ -344,6 +353,7 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
|
|
|
344
353
|
|
|
345
354
|
return transformed
|
|
346
355
|
} catch (err) {
|
|
356
|
+
if (!isLatest()) return null
|
|
347
357
|
console.error(`[useEntityItemPage] Failed to load ${entity}:`, err)
|
|
348
358
|
const axiosError = err as { response?: { data?: { detail?: string } }; message?: string }
|
|
349
359
|
error.value =
|
|
@@ -357,7 +367,7 @@ export function useEntityItemPage<T = unknown>(config: UseEntityItemPageOptions<
|
|
|
357
367
|
|
|
358
368
|
return null
|
|
359
369
|
} finally {
|
|
360
|
-
loading.value = false
|
|
370
|
+
if (isLatest()) loading.value = false
|
|
361
371
|
}
|
|
362
372
|
}
|
|
363
373
|
|
|
@@ -15,8 +15,11 @@
|
|
|
15
15
|
* @experimental Shape may change in a minor release — see docs/API_STABILITY.md.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { inject, onUnmounted } from 'vue'
|
|
18
|
+
import { inject, onUnmounted, watch } from 'vue'
|
|
19
19
|
import type { SignalBus } from '../kernel/SignalBus'
|
|
20
|
+
import { useApiClient } from '../api/apiClient'
|
|
21
|
+
import type { HttpClient } from '../entity/storage/ApiStorage'
|
|
22
|
+
import { SSE_SUBSCRIPTIONS_KEY, type SseSubscriptions } from '../kernel/sseSubscriptions'
|
|
20
23
|
|
|
21
24
|
export interface UseLiveEntityOptions {
|
|
22
25
|
/**
|
|
@@ -37,7 +40,8 @@ interface LivePolicy {
|
|
|
37
40
|
*
|
|
38
41
|
* @param entityName - entity to watch
|
|
39
42
|
* @param manager - its manager, read for the `live` policy (may be null)
|
|
40
|
-
* @param reload - what to run; coalesced, never called
|
|
43
|
+
* @param reload - what to run; coalesced, and never called while a previous
|
|
44
|
+
* call's promise is still pending — changes arriving meanwhile cause one more call
|
|
41
45
|
* @returns unsubscribe function (also called automatically on unmount)
|
|
42
46
|
*/
|
|
43
47
|
export function useLiveEntity(
|
|
@@ -60,10 +64,44 @@ export function useLiveEntity(
|
|
|
60
64
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
61
65
|
let disposed = false
|
|
62
66
|
|
|
67
|
+
// Never two reloads at once (#2666), as this composable promises: an event
|
|
68
|
+
// arriving while one is in flight marks it dirty, and exactly one more runs
|
|
69
|
+
// when it settles — no overlapping requests, and the last state lands.
|
|
70
|
+
let inFlight = false
|
|
71
|
+
let dirty = false
|
|
72
|
+
|
|
73
|
+
const settle = (): void => {
|
|
74
|
+
inFlight = false
|
|
75
|
+
if (dirty && !disposed) {
|
|
76
|
+
dirty = false
|
|
77
|
+
run()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Only a reload that returns a promise is in flight: a synchronous one is done
|
|
82
|
+
// when it returns. A failure — thrown or rejected — must not stop the next one;
|
|
83
|
+
// the page reports its own load errors.
|
|
84
|
+
const run = (): void => {
|
|
85
|
+
let result: void | Promise<void>
|
|
86
|
+
try {
|
|
87
|
+
result = reload()
|
|
88
|
+
} catch {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (result && typeof (result as Promise<void>).then === 'function') {
|
|
92
|
+
inFlight = true
|
|
93
|
+
;(result as Promise<void>).then(settle, settle)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
63
97
|
const fire = (): void => {
|
|
64
98
|
timer = null
|
|
65
99
|
if (disposed) return
|
|
66
|
-
|
|
100
|
+
if (inFlight) {
|
|
101
|
+
dirty = true
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
run()
|
|
67
105
|
}
|
|
68
106
|
|
|
69
107
|
const off = signals.on('entity:data-invalidate', (event: { name: string; data: unknown }) => {
|
|
@@ -100,6 +138,15 @@ export function useLiveEntity(
|
|
|
100
138
|
timer = setTimeout(fire, coalesceMs)
|
|
101
139
|
})
|
|
102
140
|
|
|
141
|
+
// Per-record subscription (#2664): a detail page asks the backend to send
|
|
142
|
+
// this record's changes to this tab, for as long as it shows the record.
|
|
143
|
+
const subscriptions = inject<SseSubscriptions | null>(SSE_SUBSCRIPTIONS_KEY, null)
|
|
144
|
+
const apiClient = subscriptions && options.id ? useApiClient() : null
|
|
145
|
+
const stopSubscription =
|
|
146
|
+
subscriptions && apiClient && options.id
|
|
147
|
+
? followRecord(subscriptions, apiClient, entityName, options.id, inject<boolean>('qdadmDebug', false))
|
|
148
|
+
: () => {}
|
|
149
|
+
|
|
103
150
|
const stop = (): void => {
|
|
104
151
|
disposed = true
|
|
105
152
|
if (timer) {
|
|
@@ -107,9 +154,84 @@ export function useLiveEntity(
|
|
|
107
154
|
timer = null
|
|
108
155
|
}
|
|
109
156
|
off()
|
|
157
|
+
stopSubscription()
|
|
110
158
|
}
|
|
111
159
|
|
|
112
160
|
onUnmounted(stop)
|
|
113
161
|
|
|
114
162
|
return stop
|
|
115
163
|
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Keep a subscription to the record `id()` points at: POST when it is shown,
|
|
167
|
+
* renew at half of `expires_in`, DELETE when it changes or the page leaves.
|
|
168
|
+
*
|
|
169
|
+
* Never throws into the page — without a subscription the page still works, it
|
|
170
|
+
* just does not hear about changes. Failures are said in debug mode only.
|
|
171
|
+
*/
|
|
172
|
+
function followRecord(
|
|
173
|
+
subscriptions: SseSubscriptions,
|
|
174
|
+
api: HttpClient,
|
|
175
|
+
entity: string,
|
|
176
|
+
id: () => string | number | null | undefined,
|
|
177
|
+
debug: boolean
|
|
178
|
+
): () => void {
|
|
179
|
+
let current: string | null = null
|
|
180
|
+
let renewTimer: ReturnType<typeof setTimeout> | null = null
|
|
181
|
+
let stopped = false
|
|
182
|
+
|
|
183
|
+
const body = (recordId: string) => ({ session: subscriptions.session, entity, id: recordId })
|
|
184
|
+
|
|
185
|
+
const report = (what: string, recordId: string, error: unknown): void => {
|
|
186
|
+
if (!debug) return
|
|
187
|
+
const status = (error as { response?: { status?: number } })?.response?.status
|
|
188
|
+
const reason = status ?? (error as { message?: string })?.message ?? String(error)
|
|
189
|
+
console.warn(`[qdadm] live subscription ${what} failed for ${entity} #${recordId} (${reason}) — the page will not update on its own`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const clearRenewal = (): void => {
|
|
193
|
+
if (renewTimer) clearTimeout(renewTimer)
|
|
194
|
+
renewTimer = null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const subscribe = async (recordId: string, retryAfterMs: number | null = null): Promise<void> => {
|
|
198
|
+
try {
|
|
199
|
+
const { data } = await api.post<{ expires_in?: number }>(subscriptions.url, body(recordId))
|
|
200
|
+
if (stopped || current !== recordId) return
|
|
201
|
+
const seconds = Number(data?.expires_in)
|
|
202
|
+
if (seconds > 0) renewTimer = setTimeout(() => void subscribe(recordId, (seconds * 1000) / 2), (seconds * 1000) / 2)
|
|
203
|
+
} catch (error) {
|
|
204
|
+
report('subscribe', recordId, error)
|
|
205
|
+
// A renewal that fails is tried again at the same pace: the subscription is about to lapse.
|
|
206
|
+
if (!stopped && current === recordId && retryAfterMs) {
|
|
207
|
+
renewTimer = setTimeout(() => void subscribe(recordId, retryAfterMs), retryAfterMs)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const unsubscribe = (recordId: string): void => {
|
|
213
|
+
api
|
|
214
|
+
.request({ method: 'DELETE', url: subscriptions.url, data: body(recordId) })
|
|
215
|
+
.catch((error: unknown) => report('unsubscribe', recordId, error))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const follow = (raw: string | number | null | undefined): void => {
|
|
219
|
+
const next = raw == null || raw === '' ? null : String(raw)
|
|
220
|
+
if (next === current) return
|
|
221
|
+
clearRenewal()
|
|
222
|
+
if (current) unsubscribe(current)
|
|
223
|
+
current = next
|
|
224
|
+
if (next) void subscribe(next)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const stopWatch = watch(id, follow, { immediate: true })
|
|
228
|
+
|
|
229
|
+
return () => {
|
|
230
|
+
if (stopped) return
|
|
231
|
+
stopped = true
|
|
232
|
+
stopWatch()
|
|
233
|
+
clearRenewal()
|
|
234
|
+
if (current) unsubscribe(current)
|
|
235
|
+
current = null
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -16,6 +16,7 @@ import { Orchestrator } from '../orchestrator/Orchestrator'
|
|
|
16
16
|
import type { EntityAuthAdapter } from '../entity/auth/EntityAuthAdapter'
|
|
17
17
|
import type { Kernel } from './Kernel'
|
|
18
18
|
import type { SSEConfig } from './Kernel.types'
|
|
19
|
+
import { sseTabSession, withQueryParam } from './sseSubscriptions'
|
|
19
20
|
// #1196 Phase B — this-typing against the real Kernel shape (was Self = any)
|
|
20
21
|
type Self = Kernel
|
|
21
22
|
|
|
@@ -447,6 +448,7 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
447
448
|
'getToken',
|
|
448
449
|
'connectOnSignal',
|
|
449
450
|
'disconnectOnSignal',
|
|
451
|
+
'subscriptions',
|
|
450
452
|
]),
|
|
451
453
|
{
|
|
452
454
|
getToken:
|
|
@@ -454,6 +456,7 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
454
456
|
'and query strings reach access logs',
|
|
455
457
|
entities: 'no entity cache will be invalidated from the stream',
|
|
456
458
|
tokenParam: 'the token will be sent under the default name "token"',
|
|
459
|
+
subscriptions: 'detail pages will not subscribe to their record, and the stream carries no tab session',
|
|
457
460
|
}
|
|
458
461
|
)
|
|
459
462
|
}
|
|
@@ -466,6 +469,16 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
466
469
|
|
|
467
470
|
this._validateSseConfig(sse)
|
|
468
471
|
|
|
472
|
+
// Per-record subscriptions (#2664): the tab id is minted here and put in
|
|
473
|
+
// the stream URL, so the one the pages subscribe with is the same.
|
|
474
|
+
let url = sse.url
|
|
475
|
+
this.sseSubscriptions = null
|
|
476
|
+
if (sse.subscriptions?.url) {
|
|
477
|
+
const session = sseTabSession()
|
|
478
|
+
this.sseSubscriptions = { url: sse.subscriptions.url, session }
|
|
479
|
+
url = withQueryParam(url, sse.subscriptions.sessionParam ?? 'session', session)
|
|
480
|
+
}
|
|
481
|
+
|
|
469
482
|
// An explicit sse.getToken wins — including an explicit null, which means
|
|
470
483
|
// "send no token" and must not fall back to the auth adapter.
|
|
471
484
|
const getToken =
|
|
@@ -477,7 +490,7 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
477
490
|
|
|
478
491
|
this.sseBridge = createSSEBridge({
|
|
479
492
|
signals: this.signals!,
|
|
480
|
-
url
|
|
493
|
+
url,
|
|
481
494
|
reconnectDelay: sse.reconnectDelay ?? 5000,
|
|
482
495
|
signalPrefix: sse.signalPrefix ?? 'sse',
|
|
483
496
|
autoConnect: sse.autoConnect ?? false,
|
package/src/kernel/Kernel.ts
CHANGED
|
@@ -80,6 +80,7 @@ import { isDebugBarKilled } from './debugBarKillSwitch'
|
|
|
80
80
|
import { applyApiMethods } from './Kernel.api'
|
|
81
81
|
import { applyI18nMethods } from './Kernel.i18n'
|
|
82
82
|
import type { I18n } from '../i18n/I18n'
|
|
83
|
+
import type { SseSubscriptions } from './sseSubscriptions'
|
|
83
84
|
|
|
84
85
|
export class Kernel {
|
|
85
86
|
options: KernelOptions
|
|
@@ -101,6 +102,8 @@ export class Kernel {
|
|
|
101
102
|
deferred!: DeferredRegistry
|
|
102
103
|
eventRouter: EventRouter | null = null
|
|
103
104
|
sseBridge: SSEBridge | null = null
|
|
105
|
+
/** Where detail pages subscribe to their record, and as which tab (#2664). Null without `sse.subscriptions`. */
|
|
106
|
+
sseSubscriptions: SseSubscriptions | null = null
|
|
104
107
|
/** Routes external entity changes to cache invalidation (#1888). */
|
|
105
108
|
liveEntityRouter: LiveEntityRouter | null = null
|
|
106
109
|
layoutComponents: InternalLayoutComponents | null = null
|
|
@@ -135,6 +135,23 @@ export interface SSEConfig {
|
|
|
135
135
|
* See docs/adr/0009-live-entities.md.
|
|
136
136
|
*/
|
|
137
137
|
entities?: string[] | true | '*'
|
|
138
|
+
/**
|
|
139
|
+
* Per-record subscriptions (#2664): a detail page tells the backend which
|
|
140
|
+
* record it shows, so the stream carries that record's changes to this tab.
|
|
141
|
+
*
|
|
142
|
+
* `url` receives `POST {session, entity, id}` when a detail page shows a
|
|
143
|
+
* record, answering `{expires_in}` (seconds); qdadm renews at half of it, and
|
|
144
|
+
* sends `DELETE` with the same body when the page leaves the record. Calls go
|
|
145
|
+
* through the kernel's `apiClient`, with the app's own auth.
|
|
146
|
+
*
|
|
147
|
+
* `session` is a per-tab id qdadm mints and also appends to the stream URL as
|
|
148
|
+
* `sessionParam` (default `'session'`): the stream and the subscriptions name
|
|
149
|
+
* the same tab. Do not put your own session parameter in `url`.
|
|
150
|
+
*
|
|
151
|
+
* The entity must be declared in `entities`, or its frames are dropped before
|
|
152
|
+
* any page sees them. Absent: no subscription, no session parameter.
|
|
153
|
+
*/
|
|
154
|
+
subscriptions?: { url: string; sessionParam?: string }
|
|
138
155
|
/**
|
|
139
156
|
* Token appended to the SSE URL as a query parameter.
|
|
140
157
|
*
|
package/src/kernel/Kernel.vue.ts
CHANGED
|
@@ -334,6 +334,8 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
|
|
|
334
334
|
|
|
335
335
|
if (this.sseBridge) {
|
|
336
336
|
app.provide('qdadmSSEBridge', this.sseBridge)
|
|
337
|
+
// Detail pages subscribe to their record through this (#2664); null without sse.subscriptions.
|
|
338
|
+
app.provide('qdadmSSESubscriptions', this.sseSubscriptions)
|
|
337
339
|
}
|
|
338
340
|
|
|
339
341
|
app.provide('qdadmHooks', this.hookRegistry)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-record SSE subscriptions (#2664) — the tab's identity, shared by the
|
|
3
|
+
* stream and the subscription calls.
|
|
4
|
+
*
|
|
5
|
+
* A detail page tells the backend which record it shows, so the stream carries
|
|
6
|
+
* that record's changes to this tab only. The backend has to know which stream
|
|
7
|
+
* a subscription belongs to, and the only thing both sides can name is the
|
|
8
|
+
* tab: qdadm mints one id per browser tab, appends it to the stream URL, and
|
|
9
|
+
* sends the same id with every subscription. One owner, so the two cannot
|
|
10
|
+
* disagree.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** What the kernel hands to `useLiveEntity`: where to subscribe, and as which tab. */
|
|
14
|
+
export interface SseSubscriptions {
|
|
15
|
+
url: string
|
|
16
|
+
session: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Injection key under which the kernel provides `SseSubscriptions` (or null). */
|
|
20
|
+
export const SSE_SUBSCRIPTIONS_KEY = 'qdadmSSESubscriptions'
|
|
21
|
+
|
|
22
|
+
const TAB_SESSION_KEY = 'qdadm:sse-session'
|
|
23
|
+
|
|
24
|
+
function mintId(): string {
|
|
25
|
+
// randomUUID only exists in secure contexts; a plain-http deployment still needs an id.
|
|
26
|
+
const c = typeof crypto !== 'undefined' ? crypto : null
|
|
27
|
+
if (c && typeof c.randomUUID === 'function') return c.randomUUID()
|
|
28
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* This tab's id: kept in sessionStorage, so a reload is the same tab and a new
|
|
33
|
+
* tab is a new one. Storage that throws (blocked site data) still yields an id,
|
|
34
|
+
* valid for this page load.
|
|
35
|
+
*/
|
|
36
|
+
export function sseTabSession(): string {
|
|
37
|
+
try {
|
|
38
|
+
const existing = window.sessionStorage.getItem(TAB_SESSION_KEY)
|
|
39
|
+
if (existing) return existing
|
|
40
|
+
const id = mintId()
|
|
41
|
+
window.sessionStorage.setItem(TAB_SESSION_KEY, id)
|
|
42
|
+
return id
|
|
43
|
+
} catch {
|
|
44
|
+
return mintId()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `url` with `name=value` added to its query string, before any fragment. */
|
|
49
|
+
export function withQueryParam(url: string, name: string, value: string): string {
|
|
50
|
+
const [base, fragment] = url.split('#', 2)
|
|
51
|
+
const param = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`
|
|
52
|
+
const joined = `${base}${base!.includes('?') ? '&' : '?'}${param}`
|
|
53
|
+
return fragment === undefined ? joined : `${joined}#${fragment}`
|
|
54
|
+
}
|