@morscherlab/mint-sdk 1.5.0-beta.15 → 1.5.0-beta.17
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/dist/{AlertBox-Cai0w7gF.js → AlertBox-BPiQwSlr.js} +2 -2
- package/dist/{AlertBox-Cai0w7gF.js.map → AlertBox-BPiQwSlr.js.map} +1 -1
- package/dist/{AppSidebar-B1g280Wq.js → AppSidebar-avUHlogX.js} +7 -7
- package/dist/{AppSidebar-B1g280Wq.js.map → AppSidebar-avUHlogX.js.map} +1 -1
- package/dist/{ExperimentPopover-DYy_s0kI.js → ExperimentPopover-DLRfd0ab.js} +2 -2
- package/dist/{ExperimentPopover-DYy_s0kI.js.map → ExperimentPopover-DLRfd0ab.js.map} +1 -1
- package/dist/{ExperimentSelectorModal-Bfh5bdkv.js → ExperimentSelectorModal-Brsf71MN.js} +4 -4
- package/dist/{ExperimentSelectorModal-Bfh5bdkv.js.map → ExperimentSelectorModal-Brsf71MN.js.map} +1 -1
- package/dist/{useRealtime-BLQEjbuq.js → JobsStatusTray-DWd8Hpmr.js} +4 -125
- package/dist/JobsStatusTray-DWd8Hpmr.js.map +1 -0
- package/dist/{SegmentedControl-B8KgyOjd.js → SegmentedControl-CEZbeh_h.js} +2 -2
- package/dist/{SegmentedControl-B8KgyOjd.js.map → SegmentedControl-CEZbeh_h.js.map} +1 -1
- package/dist/{SettingsModal-CeaQHyot.js → SettingsModal-kBAf-THw.js} +8 -8
- package/dist/{SettingsModal-CeaQHyot.js.map → SettingsModal-kBAf-THw.js.map} +1 -1
- package/dist/components/internal/AutoGroupModalInternal.adapter.d.ts +0 -10
- package/dist/composables/useRealtime.d.ts +19 -3
- package/dist/{controlSchemaModel-BtFNZj8t.js → controlSchemaModel-CORuwx9Z.js} +2 -2
- package/dist/{controlSchemaModel-BtFNZj8t.js.map → controlSchemaModel-CORuwx9Z.js.map} +1 -1
- package/dist/{data-CJztFns8.js → data-de4y8LO7.js} +12 -11
- package/dist/{data-CJztFns8.js.map → data-de4y8LO7.js.map} +1 -1
- package/dist/data.js +7 -7
- package/dist/{experiment-DyO54FQB.js → experiment-CNMuOEGc.js} +6 -3
- package/dist/experiment-CNMuOEGc.js.map +1 -0
- package/dist/host.d.ts +1 -0
- package/dist/host.js +2 -2
- package/dist/host.js.map +1 -1
- package/dist/index.js +14 -14
- package/dist/{install-rcT3eXD7.js → install-C6A34A9P.js} +12 -12
- package/dist/{install-rcT3eXD7.js.map → install-C6A34A9P.js.map} +1 -1
- package/dist/install.js +1 -1
- package/dist/{lab-BYhb_cJI.js → lab-CGnCGPf6.js} +9 -9
- package/dist/lab-CGnCGPf6.js.map +1 -0
- package/dist/lab.js +4 -4
- package/dist/{records-GtiVMlTQ.js → records-TEeD11BZ.js} +2 -2
- package/dist/{records-GtiVMlTQ.js.map → records-TEeD11BZ.js.map} +1 -1
- package/dist/{runtime-7_LcVjuP.js → runtime-CmgnbmnZ.js} +12 -14
- package/dist/{runtime-7_LcVjuP.js.map → runtime-CmgnbmnZ.js.map} +1 -1
- package/dist/runtime.d.ts +1 -1
- package/dist/runtime.js +7 -7
- package/dist/{shell-DnCwE5hq.js → shell-CHecDOIP.js} +4 -4
- package/dist/{shell-DnCwE5hq.js.map → shell-CHecDOIP.js.map} +1 -1
- package/dist/shell.js +4 -4
- package/dist/stores/index.js +2 -2
- package/dist/{useExperimentSamples-CpNXvpUn.js → useExperimentSamples-s2QY9Wn_.js} +4 -4
- package/dist/{useExperimentSamples-CpNXvpUn.js.map → useExperimentSamples-s2QY9Wn_.js.map} +1 -1
- package/dist/{useListReorder-Bp4EAI2w.js → useListReorder-CNAbJi9S.js} +4 -4
- package/dist/{useListReorder-Bp4EAI2w.js.map → useListReorder-CNAbJi9S.js.map} +1 -1
- package/dist/{useRackEditor-i2bdPUFf.js → useRackEditor-ldrpkIIQ.js} +8 -8
- package/dist/{useRackEditor-i2bdPUFf.js.map → useRackEditor-ldrpkIIQ.js.map} +1 -1
- package/dist/useRealtime-t7Qg2a5h.js +216 -0
- package/dist/useRealtime-t7Qg2a5h.js.map +1 -0
- package/dist/{useRequest-CXyPw3eh.js → useRequest-CcgNnT2V.js} +2 -2
- package/dist/{useRequest-CXyPw3eh.js.map → useRequest-CcgNnT2V.js.map} +1 -1
- package/package.json +1 -1
- package/src/__tests__/components/AutoGroupModal.adapter.test.ts +0 -31
- package/src/__tests__/composables/usePresence.test.ts +21 -0
- package/src/__tests__/composables/useRealtime.test.ts +226 -1
- package/src/__tests__/docs/frontendDocsCatalog.test.ts +9 -4
- package/src/components/internal/AutoGroupModalInternal.adapter.ts +0 -25
- package/src/composables/useApi.ts +5 -1
- package/src/composables/usePresence.ts +4 -4
- package/src/composables/useRealtime.ts +59 -6
- package/src/composables/useVersionedSave.ts +2 -1
- package/src/host.ts +1 -0
- package/src/runtime.ts +1 -0
- package/dist/auth-SliLnFre.js +0 -65
- package/dist/auth-SliLnFre.js.map +0 -1
- package/dist/experiment-DyO54FQB.js.map +0 -1
- package/dist/lab-BYhb_cJI.js.map +0 -1
- package/dist/useRealtime-BLQEjbuq.js.map +0 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { computed, getCurrentScope, onScopeDispose, readonly, ref, toValue, watch } from "vue";
|
|
2
|
+
import { defineStore } from "pinia";
|
|
3
|
+
//#region src/stores/auth.ts
|
|
4
|
+
/** localStorage key of the platform session token. The platform owns the stored value. */
|
|
5
|
+
var AUTH_TOKEN_STORAGE_KEY = "mint-auth-token";
|
|
6
|
+
/** localStorage key of the platform session token expiry (ISO 8601). */
|
|
7
|
+
var AUTH_EXPIRES_STORAGE_KEY = "mint-auth-expires";
|
|
8
|
+
function readStoredItem(key) {
|
|
9
|
+
try {
|
|
10
|
+
const storage = globalThis.localStorage;
|
|
11
|
+
return typeof storage?.getItem === "function" ? storage.getItem(key) : null;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Read-only session bridge between the platform and SDK code.
|
|
18
|
+
*
|
|
19
|
+
* SDK clients read the session token from here. Only the platform writes the
|
|
20
|
+
* session, through `@morscherlab/mint-sdk/host`. The SDK never writes or removes
|
|
21
|
+
* the stored platform token.
|
|
22
|
+
*/
|
|
23
|
+
var useAuthStore = defineStore("mint-auth", () => {
|
|
24
|
+
const token = ref(null);
|
|
25
|
+
const tokenExpires = ref(null);
|
|
26
|
+
const userInfo = ref(null);
|
|
27
|
+
const authRequired = ref(true);
|
|
28
|
+
const isInitialized = ref(false);
|
|
29
|
+
const isAuthenticated = computed(() => {
|
|
30
|
+
if (!authRequired.value) return true;
|
|
31
|
+
if (!token.value) return false;
|
|
32
|
+
return !(tokenExpires.value && tokenExpires.value < /* @__PURE__ */ new Date());
|
|
33
|
+
});
|
|
34
|
+
/** Read the stored platform session token, once per page. */
|
|
35
|
+
function initialize() {
|
|
36
|
+
const storedToken = readStoredItem(AUTH_TOKEN_STORAGE_KEY);
|
|
37
|
+
const storedExpires = readStoredItem(AUTH_EXPIRES_STORAGE_KEY);
|
|
38
|
+
const expires = storedExpires ? new Date(storedExpires) : null;
|
|
39
|
+
if (storedToken && !(expires && expires <= /* @__PURE__ */ new Date())) {
|
|
40
|
+
token.value = storedToken;
|
|
41
|
+
tokenExpires.value = expires;
|
|
42
|
+
}
|
|
43
|
+
isInitialized.value = true;
|
|
44
|
+
}
|
|
45
|
+
/** Forget the session in memory after the server rejected its token. */
|
|
46
|
+
function clearToken() {
|
|
47
|
+
token.value = null;
|
|
48
|
+
tokenExpires.value = null;
|
|
49
|
+
userInfo.value = null;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
token,
|
|
53
|
+
tokenExpires,
|
|
54
|
+
userInfo,
|
|
55
|
+
authRequired,
|
|
56
|
+
isInitialized,
|
|
57
|
+
isAuthenticated,
|
|
58
|
+
initialize,
|
|
59
|
+
clearToken
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/composables/useRealtime.ts
|
|
64
|
+
var EVENTS_URL = "/api/events";
|
|
65
|
+
var RECONNECT_BASE_MS = 1e3;
|
|
66
|
+
var RECONNECT_MAX_MS = 3e4;
|
|
67
|
+
/** Merges the subscriptions of components that mount together into one connection. */
|
|
68
|
+
var TOPIC_DEBOUNCE_MS = 50;
|
|
69
|
+
var RESYNC = { type: "resync" };
|
|
70
|
+
var listeners = /* @__PURE__ */ new Set();
|
|
71
|
+
/** The last `presence.changed` of each topic of the open stream; the server sends one per topic at open. */
|
|
72
|
+
var presence = /* @__PURE__ */ new Map();
|
|
73
|
+
var connected = ref(false);
|
|
74
|
+
var source = null;
|
|
75
|
+
var sourceTopics = "";
|
|
76
|
+
var failures = 0;
|
|
77
|
+
var syncTimer = null;
|
|
78
|
+
var retryTimer = null;
|
|
79
|
+
/** The lowest `X-MINT-Event-Seq` of the responses since the page loaded, and their boot id (`''` once two differ). */
|
|
80
|
+
var tracked = null;
|
|
81
|
+
/** The cursor the current stream was opened with. */
|
|
82
|
+
var streamCursor = null;
|
|
83
|
+
/** @internal Record the `X-MINT-Event-Seq` / `X-MINT-Boot-Id` headers of one platform response. */
|
|
84
|
+
function noteEventSeq(seq, bootId) {
|
|
85
|
+
const value = Number(seq);
|
|
86
|
+
if (!seq || !bootId || !Number.isSafeInteger(value)) return;
|
|
87
|
+
if (streamCursor && (value < streamCursor.seq || bootId !== streamCursor.boot)) {
|
|
88
|
+
streamCursor = {
|
|
89
|
+
seq: value,
|
|
90
|
+
boot: bootId
|
|
91
|
+
};
|
|
92
|
+
dispatch(RESYNC);
|
|
93
|
+
}
|
|
94
|
+
tracked = tracked ? {
|
|
95
|
+
seq: Math.min(tracked.seq, value),
|
|
96
|
+
boot: tracked.boot === bootId ? tracked.boot : ""
|
|
97
|
+
} : {
|
|
98
|
+
seq: value,
|
|
99
|
+
boot: bootId
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function dispatch(event) {
|
|
103
|
+
const isPresence = event.type === "presence.changed";
|
|
104
|
+
if (isPresence && event.topic) presence.set(event.topic, event);
|
|
105
|
+
for (const listener of [...listeners]) {
|
|
106
|
+
if (isPresence && !listener.presence) continue;
|
|
107
|
+
if (event.type === "resync" || event.topic && listener.topics().includes(event.topic)) listener.handler(event);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function close() {
|
|
111
|
+
source?.close();
|
|
112
|
+
source = null;
|
|
113
|
+
sourceTopics = "";
|
|
114
|
+
presence.clear();
|
|
115
|
+
streamCursor = null;
|
|
116
|
+
connected.value = false;
|
|
117
|
+
}
|
|
118
|
+
function open(topics) {
|
|
119
|
+
const cursor = tracked && [...listeners].every((listener) => listener.cursor) ? tracked : null;
|
|
120
|
+
const query = cursor ? `&since=${cursor.seq}&boot=${encodeURIComponent(cursor.boot)}` : "";
|
|
121
|
+
const stream = new EventSource(`${EVENTS_URL}?topics=${encodeURIComponent(topics)}${query}`);
|
|
122
|
+
source = stream;
|
|
123
|
+
sourceTopics = topics;
|
|
124
|
+
streamCursor = cursor;
|
|
125
|
+
stream.onopen = () => {
|
|
126
|
+
connected.value = true;
|
|
127
|
+
if (!cursor || ![...listeners].every((listener) => listener.cursor)) dispatch(RESYNC);
|
|
128
|
+
failures = 0;
|
|
129
|
+
};
|
|
130
|
+
stream.onmessage = (message) => {
|
|
131
|
+
dispatch(JSON.parse(message.data));
|
|
132
|
+
};
|
|
133
|
+
stream.onerror = () => {
|
|
134
|
+
if (source !== stream) return;
|
|
135
|
+
close();
|
|
136
|
+
if (syncTimer !== null) clearTimeout(syncTimer);
|
|
137
|
+
syncTimer = null;
|
|
138
|
+
retryTimer = setTimeout(() => {
|
|
139
|
+
retryTimer = null;
|
|
140
|
+
sync();
|
|
141
|
+
}, Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** failures));
|
|
142
|
+
failures += 1;
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function sync() {
|
|
146
|
+
syncTimer = null;
|
|
147
|
+
const topics = [...new Set([...listeners].flatMap((listener) => listener.topics()))].sort().join(",");
|
|
148
|
+
if (source && topics === sourceTopics) return;
|
|
149
|
+
close();
|
|
150
|
+
if (topics) open(topics);
|
|
151
|
+
}
|
|
152
|
+
function schedule() {
|
|
153
|
+
if (retryTimer !== null) return;
|
|
154
|
+
if (syncTimer !== null) clearTimeout(syncTimer);
|
|
155
|
+
syncTimer = setTimeout(sync, TOPIC_DEBOUNCE_MS);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Subscribe to topics of the platform's realtime stream.
|
|
159
|
+
*
|
|
160
|
+
* Every caller in a tab shares one `EventSource`, opened on the union of
|
|
161
|
+
* their topics and reopened when that union changes. `handler` receives the
|
|
162
|
+
* write events of the caller's own topics (not `presence.changed`, which goes
|
|
163
|
+
* to `usePresence`) plus every `resync`: one comes from the
|
|
164
|
+
* server when this tab fell behind, and one follows each open of the stream:
|
|
165
|
+
* the first open, each reconnect (backoff from 1 s to 30 s) and each reopen for
|
|
166
|
+
* a changed topic union. With `options.cursor` set by every caller, the open
|
|
167
|
+
* sends no `resync` of its own; the server sends one when a topic changed since
|
|
168
|
+
* the oldest recorded REST load. On `resync`, refetch. The union of all callers' topics
|
|
169
|
+
* in a tab may hold at most 50 topics; the platform refuses a larger stream.
|
|
170
|
+
*
|
|
171
|
+
* The stream authenticates with the session cookie, so it works in the
|
|
172
|
+
* platform and in plugin frontends served by it. It exists only when the
|
|
173
|
+
* platform's `realtime` feature flag is on; check that flag first.
|
|
174
|
+
*
|
|
175
|
+
* A plugin topic reaches a caller whose role may use the plugin; an
|
|
176
|
+
* experiment plugin topic also needs a caller who may view the experiment.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* useRealtime(() => [`experiment:${id.value}`], () => refetch())
|
|
180
|
+
* @example
|
|
181
|
+
* useRealtime(['plugin:drp'], (event) => {
|
|
182
|
+
* if (event.type === 'drp.run.progress') progress.value = event.payload as RunProgress
|
|
183
|
+
* })
|
|
184
|
+
*/
|
|
185
|
+
function useRealtime(topics, handler, options = {}) {
|
|
186
|
+
return subscribeRealtime(topics, handler, false, options.cursor ?? false);
|
|
187
|
+
}
|
|
188
|
+
/** The last `presence.changed` of `topic` while the open stream views it (for `usePresence`). */
|
|
189
|
+
function currentPresence(topic) {
|
|
190
|
+
return presence.get(topic);
|
|
191
|
+
}
|
|
192
|
+
/** `useRealtime`, and with `withPresence` also the `presence.changed` events (for `usePresence`). */
|
|
193
|
+
function subscribeRealtime(topics, handler, withPresence, cursor = false) {
|
|
194
|
+
const listener = {
|
|
195
|
+
topics: () => toValue(topics),
|
|
196
|
+
handler,
|
|
197
|
+
presence: withPresence,
|
|
198
|
+
cursor: cursor || withPresence
|
|
199
|
+
};
|
|
200
|
+
listeners.add(listener);
|
|
201
|
+
const stopWatch = watch(() => listener.topics().join(","), () => schedule());
|
|
202
|
+
schedule();
|
|
203
|
+
function stop() {
|
|
204
|
+
stopWatch();
|
|
205
|
+
if (listeners.delete(listener)) schedule();
|
|
206
|
+
}
|
|
207
|
+
if (getCurrentScope()) onScopeDispose(stop);
|
|
208
|
+
return {
|
|
209
|
+
connected: readonly(connected),
|
|
210
|
+
stop
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
//#endregion
|
|
214
|
+
export { AUTH_EXPIRES_STORAGE_KEY as a, useRealtime as i, noteEventSeq as n, AUTH_TOKEN_STORAGE_KEY as o, subscribeRealtime as r, useAuthStore as s, currentPresence as t };
|
|
215
|
+
|
|
216
|
+
//# sourceMappingURL=useRealtime-t7Qg2a5h.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useRealtime-t7Qg2a5h.js","names":[],"sources":["../src/stores/auth.ts","../src/composables/useRealtime.ts"],"sourcesContent":["import { defineStore } from 'pinia'\nimport { ref, computed } from 'vue'\nimport type { UserInfo } from '../types'\n\n/** localStorage key of the platform session token. The platform owns the stored value. */\nexport const AUTH_TOKEN_STORAGE_KEY = 'mint-auth-token'\n/** localStorage key of the platform session token expiry (ISO 8601). */\nexport const AUTH_EXPIRES_STORAGE_KEY = 'mint-auth-expires'\n\nfunction readStoredItem(key: string): string | null {\n try {\n const storage = globalThis.localStorage\n return typeof storage?.getItem === 'function' ? storage.getItem(key) : null\n } catch {\n return null\n }\n}\n\n/**\n * Read-only session bridge between the platform and SDK code.\n *\n * SDK clients read the session token from here. Only the platform writes the\n * session, through `@morscherlab/mint-sdk/host`. The SDK never writes or removes\n * the stored platform token.\n */\nexport const useAuthStore = defineStore('mint-auth', () => {\n const token = ref<string | null>(null)\n const tokenExpires = ref<Date | null>(null)\n const userInfo = ref<UserInfo | null>(null)\n const authRequired = ref(true)\n const isInitialized = ref(false)\n\n const isAuthenticated = computed(() => {\n if (!authRequired.value) return true\n if (!token.value) return false\n return !(tokenExpires.value && tokenExpires.value < new Date())\n })\n\n /** Read the stored platform session token, once per page. */\n function initialize() {\n const storedToken = readStoredItem(AUTH_TOKEN_STORAGE_KEY)\n const storedExpires = readStoredItem(AUTH_EXPIRES_STORAGE_KEY)\n const expires = storedExpires ? new Date(storedExpires) : null\n\n if (storedToken && !(expires && expires <= new Date())) {\n token.value = storedToken\n tokenExpires.value = expires\n }\n\n isInitialized.value = true\n }\n\n /** Forget the session in memory after the server rejected its token. */\n function clearToken() {\n token.value = null\n tokenExpires.value = null\n userInfo.value = null\n }\n\n return {\n token,\n tokenExpires,\n userInfo,\n authRequired,\n isInitialized,\n isAuthenticated,\n initialize,\n clearToken,\n }\n})\n","import {\n getCurrentScope,\n onScopeDispose,\n readonly,\n ref,\n toValue,\n watch,\n type MaybeRefOrGetter,\n type Ref,\n} from 'vue'\n\n/**\n * One event of the platform's realtime stream (`GET /api/events`).\n *\n * Platform events carry no entity data: refetch the entity through REST.\n * `type` is `resync` (refetch everything you show), the audit action of a\n * committed write, such as `experiment.update` or `analysis.artifact_create`,\n * or the `\"<plugin>.<event>\"` name of a plugin event, which carries `payload`.\n * Only `usePresence` receives `presence.changed`.\n */\nexport interface RealtimeEvent {\n type: string\n id?: string\n /**\n * `experiment:{id}`, `project:{id}`, `experiments`, `user:{id}`, or a plugin's\n * `plugin:{name}` / `plugin:{name}:experiment:{id}`; absent on `resync`.\n */\n topic?: string\n /** A plugin event: the JSON payload the plugin published (at most 16 KiB). */\n payload?: unknown\n /** Changed fields of an update, when the write names them. */\n fields?: string[] | null\n actor_id?: string | null\n request_id?: string | null\n at?: string\n /** `presence.changed`: the topic's viewers (at most 20, `editing` first). */\n viewers?: { user_id: string, activity: string }[]\n /** `presence.changed`: the number of viewers, also those beyond `viewers`. */\n count?: number\n}\n\nexport type RealtimeHandler = (event: RealtimeEvent) => void\n\nexport interface UseRealtimeOptions {\n /**\n * The caller loads all its data through a client that records\n * `X-MINT-Event-Seq`: `useApi()`, a `createClient` client (it sends each\n * request through `useApi()`) or the platform's own client. Responses of a\n * plain `fetch` or another HTTP client are not recorded, so a caller that\n * loads through them must not set it. When every caller of the tab sets it, the stream opens with the\n * lowest recorded sequence since the page loaded, and the server sends\n * `resync` only when a topic changed since then, instead of one at each open.\n */\n cursor?: boolean\n}\n\nexport interface UseRealtimeReturn {\n /** True while the shared stream is open. */\n connected: Readonly<Ref<boolean>>\n /** Unsubscribe; also runs when the calling component or effect scope is disposed. */\n stop: () => void\n}\n\nconst EVENTS_URL = '/api/events'\nconst RECONNECT_BASE_MS = 1_000\nconst RECONNECT_MAX_MS = 30_000\n/** Merges the subscriptions of components that mount together into one connection. */\nconst TOPIC_DEBOUNCE_MS = 50\nconst RESYNC: RealtimeEvent = { type: 'resync' }\n\ninterface Listener {\n topics: () => string[]\n handler: RealtimeHandler\n /** Receives `presence.changed`; a write listener would refetch on each viewer change. */\n presence: boolean\n /** Its data loads record their event cursor (see `UseRealtimeOptions.cursor`). */\n cursor: boolean\n}\n\ninterface EventCursor {\n seq: number\n boot: string\n}\n\nconst listeners = new Set<Listener>()\n/** The last `presence.changed` of each topic of the open stream; the server sends one per topic at open. */\nconst presence = new Map<string, RealtimeEvent>()\nconst connected = ref(false)\nlet source: EventSource | null = null\nlet sourceTopics = ''\nlet failures = 0\nlet syncTimer: ReturnType<typeof setTimeout> | null = null\nlet retryTimer: ReturnType<typeof setTimeout> | null = null\n/** The lowest `X-MINT-Event-Seq` of the responses since the page loaded, and their boot id (`''` once two differ). */\nlet tracked: EventCursor | null = null\n/** The cursor the current stream was opened with. */\nlet streamCursor: EventCursor | null = null\n\n/** @internal Record the `X-MINT-Event-Seq` / `X-MINT-Boot-Id` headers of one platform response. */\nexport function noteEventSeq(seq: string | null | undefined, bootId: string | null | undefined): void {\n const value = Number(seq)\n // No header: the response tells nothing (an older platform, another origin).\n if (!seq || !bootId || !Number.isSafeInteger(value)) return\n // A request that started before the stream's cursor, or on another process,\n // may miss an event the server did not check: refetch.\n if (streamCursor && (value < streamCursor.seq || bootId !== streamCursor.boot)) {\n // Moved, so each late condition refetches once instead of on every refetch response.\n streamCursor = { seq: value, boot: bootId }\n dispatch(RESYNC)\n }\n // Sequences of two processes do not compare: '' never matches, so every later open resyncs.\n tracked = tracked\n ? { seq: Math.min(tracked.seq, value), boot: tracked.boot === bootId ? tracked.boot : '' }\n : { seq: value, boot: bootId }\n}\n\nfunction dispatch(event: RealtimeEvent): void {\n const isPresence = event.type === 'presence.changed'\n if (isPresence && event.topic) presence.set(event.topic, event)\n for (const listener of [...listeners]) {\n if (isPresence && !listener.presence) continue\n if (event.type === 'resync' || (event.topic && listener.topics().includes(event.topic))) {\n listener.handler(event)\n }\n }\n}\n\nfunction close(): void {\n source?.close()\n source = null\n sourceTopics = ''\n presence.clear()\n streamCursor = null\n connected.value = false\n}\n\nfunction open(topics: string): void {\n const cursor = tracked && [...listeners].every(listener => listener.cursor) ? tracked : null\n const query = cursor ? `&since=${cursor.seq}&boot=${encodeURIComponent(cursor.boot)}` : ''\n const stream = new EventSource(`${EVENTS_URL}?topics=${encodeURIComponent(topics)}${query}`)\n source = stream\n sourceTopics = topics\n streamCursor = cursor\n stream.onopen = () => {\n connected.value = true\n // The server subscribes only now: a write between a caller's REST load (or\n // the close of the previous stream) and this point sent no event. With a\n // cursor the server sends `resync` when needed; otherwise every caller refetches.\n // A caller that did not opt in may have joined while the stream connected.\n if (!cursor || ![...listeners].every(listener => listener.cursor)) dispatch(RESYNC)\n failures = 0\n }\n stream.onmessage = (message) => {\n dispatch(JSON.parse(message.data) as RealtimeEvent)\n }\n stream.onerror = () => {\n // EventSource retries a dropped connection itself but gives up after an\n // HTTP error, so one backoff path handles both.\n if (source !== stream) return\n close()\n // A topic change during the wait does not shorten it: the retry reads the topics when it runs.\n if (syncTimer !== null) clearTimeout(syncTimer)\n syncTimer = null\n retryTimer = setTimeout(() => {\n retryTimer = null\n sync()\n }, Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** failures))\n failures += 1\n }\n}\n\nfunction sync(): void {\n syncTimer = null\n const topics = [...new Set([...listeners].flatMap(listener => listener.topics()))].sort().join(',')\n if (source && topics === sourceTopics) return\n close()\n if (topics) open(topics)\n}\n\nfunction schedule(): void {\n if (retryTimer !== null) return\n if (syncTimer !== null) clearTimeout(syncTimer)\n syncTimer = setTimeout(sync, TOPIC_DEBOUNCE_MS)\n}\n\n/**\n * Subscribe to topics of the platform's realtime stream.\n *\n * Every caller in a tab shares one `EventSource`, opened on the union of\n * their topics and reopened when that union changes. `handler` receives the\n * write events of the caller's own topics (not `presence.changed`, which goes\n * to `usePresence`) plus every `resync`: one comes from the\n * server when this tab fell behind, and one follows each open of the stream:\n * the first open, each reconnect (backoff from 1 s to 30 s) and each reopen for\n * a changed topic union. With `options.cursor` set by every caller, the open\n * sends no `resync` of its own; the server sends one when a topic changed since\n * the oldest recorded REST load. On `resync`, refetch. The union of all callers' topics\n * in a tab may hold at most 50 topics; the platform refuses a larger stream.\n *\n * The stream authenticates with the session cookie, so it works in the\n * platform and in plugin frontends served by it. It exists only when the\n * platform's `realtime` feature flag is on; check that flag first.\n *\n * A plugin topic reaches a caller whose role may use the plugin; an\n * experiment plugin topic also needs a caller who may view the experiment.\n *\n * @example\n * useRealtime(() => [`experiment:${id.value}`], () => refetch())\n * @example\n * useRealtime(['plugin:drp'], (event) => {\n * if (event.type === 'drp.run.progress') progress.value = event.payload as RunProgress\n * })\n */\nexport function useRealtime(\n topics: MaybeRefOrGetter<string[]>,\n handler: RealtimeHandler,\n options: UseRealtimeOptions = {},\n): UseRealtimeReturn {\n return subscribeRealtime(topics, handler, false, options.cursor ?? false)\n}\n\n/** The last `presence.changed` of `topic` while the open stream views it (for `usePresence`). */\nexport function currentPresence(topic: string): RealtimeEvent | undefined {\n return presence.get(topic)\n}\n\n/** `useRealtime`, and with `withPresence` also the `presence.changed` events (for `usePresence`). */\nexport function subscribeRealtime(\n topics: MaybeRefOrGetter<string[]>,\n handler: RealtimeHandler,\n withPresence: boolean,\n cursor = false,\n): UseRealtimeReturn {\n // A presence caller does not refetch on resync, so it never needs the client-side one.\n const listener: Listener = { topics: () => toValue(topics), handler, presence: withPresence, cursor: cursor || withPresence }\n listeners.add(listener)\n const stopWatch = watch(() => listener.topics().join(','), () => schedule())\n schedule()\n\n function stop(): void {\n stopWatch()\n if (listeners.delete(listener)) schedule()\n }\n\n if (getCurrentScope()) onScopeDispose(stop)\n return { connected: readonly(connected), stop }\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as readErrorMessage } from "./experiment-
|
|
1
|
+
import { r as readErrorMessage } from "./experiment-CNMuOEGc.js";
|
|
2
2
|
import { ref } from "vue";
|
|
3
3
|
//#region src/composables/useRequest.ts
|
|
4
4
|
/** Loading, error and timestamp state for one request at a time; `run()` drives it. */
|
|
@@ -73,4 +73,4 @@ function useRequest(defaultErrorMessage = "Request failed.") {
|
|
|
73
73
|
//#endregion
|
|
74
74
|
export { useRequest as t };
|
|
75
75
|
|
|
76
|
-
//# sourceMappingURL=useRequest-
|
|
76
|
+
//# sourceMappingURL=useRequest-CcgNnT2V.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useRequest-
|
|
1
|
+
{"version":3,"file":"useRequest-CcgNnT2V.js","names":[],"sources":["../src/composables/useRequest.ts"],"sourcesContent":["import { ref, type Ref } from 'vue'\nimport { readErrorMessage as readSharedErrorMessage } from '../utils/errors'\n\nexport type RequestKind = 'load' | 'save' | 'run'\n\n/**\n * The unit of work `run()` drives.\n *\n * It receives an `AbortSignal` that fires when the run is superseded by a\n * newer `run()` or by `cancel()`. Zero-argument operations stay valid and\n * simply ignore it.\n */\nexport type RequestOperation<T> = (signal: AbortSignal) => Promise<T>\n\nexport interface RequestRunOptions {\n /** Which timestamp a successful run moves: `lastLoadedAt`, `lastSavedAt` or `lastRunAt`. */\n kind?: RequestKind\n errorMessage?: string\n /**\n * Abort the previously started run when this one starts. Default `true`;\n * pass `false` to let overlapping runs finish their own work (the stale\n * guard on shared state still applies either way).\n */\n abortPrevious?: boolean\n /** Caller-owned signal; aborting it aborts this run's signal too. */\n signal?: AbortSignal\n}\n\nexport interface UseRequestReturn {\n /** Whether a request is in progress. */\n isLoading: Ref<boolean>\n /** Message from the last failed request, or null. */\n error: Ref<string | null>\n /** Timestamp of the last successful load operation, or null. */\n lastLoadedAt: Ref<Date | null>\n /** Timestamp of the last successful save operation, or null. */\n lastSavedAt: Ref<Date | null>\n /** Timestamp of the last successful run operation, or null. */\n lastRunAt: Ref<Date | null>\n /** Clear the current error without changing loading or timestamps. */\n clearError: () => void\n /** Convert thrown values into a developer-facing error message. */\n readErrorMessage: (value: unknown, fallback?: string) => string\n /** Store and return a normalized error message. */\n setError: (value: unknown, fallback?: string) => string\n /**\n * Run a request with shared loading/error handling and optional sync marking.\n *\n * ## Stale-response semantics\n *\n * Every call takes a monotonically increasing token. Starting a run\n * supersedes any run started before it, and a superseded run writes **no**\n * shared state when it finally settles: it does not clear `isLoading`, does not\n * set `error`, and does not move `lastLoadedAt` / `lastSavedAt` / `lastRunAt`.\n * Only the newest run owns those.\n *\n * The caller's own promise is untouched by the guard — a superseded run still\n * resolves with its value or rejects with its own error, exactly as the\n * operation did. The guard never invents a rejection, so a caller awaiting\n * `run()` never sees a \"superseded\" error it did not cause.\n *\n * The operation additionally receives an `AbortSignal` that fires when its\n * run is superseded or `cancel()` is called, so an operation that forwards\n * the signal (`fetch(url, { signal })`) stops work instead of racing.\n */\n run: <T>(operation: RequestOperation<T>, options?: RequestRunOptions) => Promise<T>\n /**\n * Abort the in-flight run and drop `isLoading`. The aborted run's completion\n * is treated as stale and writes no state.\n */\n cancel: () => void\n}\n\n/** Loading, error and timestamp state for one request at a time; `run()` drives it. */\nexport function useRequest(\n defaultErrorMessage = 'Request failed.',\n): UseRequestReturn {\n const isLoading = ref(false)\n const error = ref<string | null>(null)\n const lastLoadedAt = ref<Date | null>(null)\n const lastSavedAt = ref<Date | null>(null)\n const lastRunAt = ref<Date | null>(null)\n\n function clearError(): void {\n error.value = null\n }\n\n function readErrorMessage(value: unknown, fallback = defaultErrorMessage): string {\n return readSharedErrorMessage(value, fallback)\n }\n\n function setError(value: unknown, fallback?: string): string {\n const message = readErrorMessage(value, fallback)\n error.value = message\n return message\n }\n\n // Monotonic token guarding every write to the shared loading/error/timestamp\n // state. Overlapping runs settle in any order, and a superseded one must not\n // clear a newer run's loading flag or overwrite its error.\n let runToken = 0\n let activeController: AbortController | null = null\n\n function cancel(): void {\n runToken += 1\n activeController?.abort()\n activeController = null\n isLoading.value = false\n }\n\n async function run<T>(\n operation: RequestOperation<T>,\n options: RequestRunOptions = {},\n ): Promise<T> {\n if (options.abortPrevious !== false) {\n activeController?.abort()\n }\n\n const token = (runToken += 1)\n const controller = new AbortController()\n activeController = controller\n\n const callerSignal = options.signal\n if (callerSignal) {\n if (callerSignal.aborted) controller.abort(callerSignal.reason)\n else callerSignal.addEventListener(\n 'abort',\n () => controller.abort(callerSignal.reason),\n { once: true },\n )\n }\n\n const isCurrent = (): boolean => token === runToken\n\n isLoading.value = true\n clearError()\n try {\n const response = await operation(controller.signal)\n if (isCurrent() && options.kind) {\n const settledAt = new Date()\n if (options.kind === 'load') lastLoadedAt.value = settledAt\n else if (options.kind === 'save') lastSavedAt.value = settledAt\n else lastRunAt.value = settledAt\n }\n return response\n } catch (err) {\n if (isCurrent()) {\n setError(err, options.errorMessage)\n }\n throw err\n } finally {\n if (isCurrent()) {\n isLoading.value = false\n }\n if (activeController === controller) {\n activeController = null\n }\n }\n }\n\n return {\n isLoading,\n error,\n lastLoadedAt,\n lastSavedAt,\n lastRunAt,\n clearError,\n readErrorMessage,\n setError,\n run,\n cancel,\n }\n}\n"],"mappings":";;;;AA0EA,SAAgB,WACd,sBAAsB,mBACJ;CAClB,MAAM,YAAY,IAAI,KAAK;CAC3B,MAAM,QAAQ,IAAmB,IAAI;CACrC,MAAM,eAAe,IAAiB,IAAI;CAC1C,MAAM,cAAc,IAAiB,IAAI;CACzC,MAAM,YAAY,IAAiB,IAAI;CAEvC,SAAS,aAAmB;EAC1B,MAAM,QAAQ;CAChB;CAEA,SAAS,mBAAiB,OAAgB,WAAW,qBAA6B;EAChF,OAAO,iBAAuB,OAAO,QAAQ;CAC/C;CAEA,SAAS,SAAS,OAAgB,UAA2B;EAC3D,MAAM,UAAU,mBAAiB,OAAO,QAAQ;EAChD,MAAM,QAAQ;EACd,OAAO;CACT;CAKA,IAAI,WAAW;CACf,IAAI,mBAA2C;CAE/C,SAAS,SAAe;EACtB,YAAY;EACZ,kBAAkB,MAAM;EACxB,mBAAmB;EACnB,UAAU,QAAQ;CACpB;CAEA,eAAe,IACb,WACA,UAA6B,CAAC,GAClB;EACZ,IAAI,QAAQ,kBAAkB,OAC5B,kBAAkB,MAAM;EAG1B,MAAM,QAAS,YAAY;EAC3B,MAAM,aAAa,IAAI,gBAAgB;EACvC,mBAAmB;EAEnB,MAAM,eAAe,QAAQ;EAC7B,IAAI,cAAc;GAChB,IAAI,aAAa,SAAS,WAAW,MAAM,aAAa,MAAM;QACzD,aAAa,iBAChB,eACM,WAAW,MAAM,aAAa,MAAM,GAC1C,EAAE,MAAM,KAAK,CACf;EACF;EAEA,MAAM,kBAA2B,UAAU;EAE3C,UAAU,QAAQ;EAClB,WAAW;EACX,IAAI;GACF,MAAM,WAAW,MAAM,UAAU,WAAW,MAAM;GAClD,IAAI,UAAU,KAAK,QAAQ,MAAM;IAC/B,MAAM,4BAAY,IAAI,KAAK;IAC3B,IAAI,QAAQ,SAAS,QAAQ,aAAa,QAAQ;SAC7C,IAAI,QAAQ,SAAS,QAAQ,YAAY,QAAQ;SACjD,UAAU,QAAQ;GACzB;GACA,OAAO;EACT,SAAS,KAAK;GACZ,IAAI,UAAU,GACZ,SAAS,KAAK,QAAQ,YAAY;GAEpC,MAAM;EACR,UAAU;GACR,IAAI,UAAU,GACZ,UAAU,QAAQ;GAEpB,IAAI,qBAAqB,YACvB,mBAAmB;EAEvB;CACF;CAEA,OAAO;EACL;EACA;EACA;EACA;EACA;EACA;EACA,kBAAA;EACA;EACA;EACA;CACF;AACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morscherlab/mint-sdk",
|
|
3
|
-
"version": "1.5.0-beta.
|
|
3
|
+
"version": "1.5.0-beta.17",
|
|
4
4
|
"description": "MINT Platform SDK — Vue 3 components, composables, and types for plugin development. MINT = Mass-spec Infrastructure for Next-generation Toolkits.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import {
|
|
3
3
|
candidateColumns,
|
|
4
|
-
engineFieldsToCards,
|
|
5
4
|
engineGroupsToRail,
|
|
6
5
|
engineSchemasToCards,
|
|
7
6
|
engineToQcRoute,
|
|
@@ -56,36 +55,6 @@ describe('candidateColumns', () => {
|
|
|
56
55
|
})
|
|
57
56
|
})
|
|
58
57
|
|
|
59
|
-
describe('engineFieldsToCards', () => {
|
|
60
|
-
it('should build cards with role label, joined values, rep flag, and aligned enabled state', () => {
|
|
61
|
-
const columns = [
|
|
62
|
-
col({ index: 0, name: 'Condition', role: 'factor', uniqueValues: ['Control', 'Treatment'] }),
|
|
63
|
-
col({ index: 1, name: 'Replicate', role: 'replicate', uniqueValues: ['Rep1', 'Rep2'] }),
|
|
64
|
-
]
|
|
65
|
-
const { fields, enabled } = engineFieldsToCards(columns, [0])
|
|
66
|
-
expect(fields).toEqual([
|
|
67
|
-
{ key: 'Condition', role: 'Factor', vals: 'Control, Treatment', rep: false },
|
|
68
|
-
{ key: 'Replicate', role: 'Replicate', vals: 'Rep1, Rep2', rep: true },
|
|
69
|
-
])
|
|
70
|
-
expect(enabled).toEqual([true, false])
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('should prefer displayName over name when present', () => {
|
|
74
|
-
const columns = [col({ index: 0, name: 'c0', displayName: 'Dose', role: 'factor', uniqueValues: ['Low'] })]
|
|
75
|
-
expect(engineFieldsToCards(columns, []).fields[0].key).toBe('Dose')
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('should map each card to its 1-based hierarchy layer from the groupBy order', () => {
|
|
79
|
-
const columns = [
|
|
80
|
-
col({ index: 0, name: 'Tissue', role: 'factor' }),
|
|
81
|
-
col({ index: 1, name: 'Timepoint', role: 'factor' }),
|
|
82
|
-
col({ index: 2, name: 'Rep', role: 'replicate' }),
|
|
83
|
-
]
|
|
84
|
-
// Layer 1 = Timepoint (column 1), layer 2 = Tissue (column 0); Rep is off.
|
|
85
|
-
expect(engineFieldsToCards(columns, [1, 0]).layers).toEqual([2, 1, null])
|
|
86
|
-
})
|
|
87
|
-
})
|
|
88
|
-
|
|
89
58
|
describe('engineSchemasToCards', () => {
|
|
90
59
|
it('should expose enabled fields from another schema when the active schema has none enabled', () => {
|
|
91
60
|
const schemas: Record<string, ClassSchema> = {
|
|
@@ -35,6 +35,11 @@ async function loadBoth() {
|
|
|
35
35
|
return { usePresence, useRealtime: (await import('../../composables/useRealtime')).useRealtime }
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
async function loadPresenceAndTracker() {
|
|
39
|
+
const usePresence = await loadUsePresence()
|
|
40
|
+
return { usePresence, noteEventSeq: (await import('../../composables/useRealtime')).noteEventSeq }
|
|
41
|
+
}
|
|
42
|
+
|
|
38
43
|
const alice = { user_id: '7', activity: 'editing' }
|
|
39
44
|
|
|
40
45
|
describe('usePresence', () => {
|
|
@@ -123,6 +128,22 @@ describe('usePresence', () => {
|
|
|
123
128
|
scope.stop()
|
|
124
129
|
})
|
|
125
130
|
|
|
131
|
+
it('should report the activity when the stream opens with the cursor and sends no resync', async () => {
|
|
132
|
+
const { usePresence, noteEventSeq } = await loadPresenceAndTracker()
|
|
133
|
+
const scope = effectScope()
|
|
134
|
+
scope.run(() => usePresence('experiment:4', 'editing'))
|
|
135
|
+
noteEventSeq('7', 'boot-a')
|
|
136
|
+
vi.runAllTimers()
|
|
137
|
+
|
|
138
|
+
latest().onopen?.()
|
|
139
|
+
|
|
140
|
+
expect([new URL(latest().url, 'http://test').searchParams.get('since'), post.mock.calls]).toEqual([
|
|
141
|
+
'7',
|
|
142
|
+
[['/presence/activity', { topic: 'experiment:4', activity: 'editing' }]],
|
|
143
|
+
])
|
|
144
|
+
scope.stop()
|
|
145
|
+
})
|
|
146
|
+
|
|
126
147
|
it('should show the viewers and report the activity when the open stream already views the topic', async () => {
|
|
127
148
|
const { usePresence, useRealtime } = await loadBoth()
|
|
128
149
|
const scope = effectScope()
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { effectScope, ref } from 'vue'
|
|
1
|
+
import { effectScope, nextTick, ref } from 'vue'
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
3
|
|
|
4
4
|
class FakeEventSource {
|
|
@@ -23,12 +23,22 @@ class FakeEventSource {
|
|
|
23
23
|
get topics(): string {
|
|
24
24
|
return new URL(this.url, 'http://test').searchParams.get('topics') ?? ''
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
get cursor(): [string | null, string | null] {
|
|
28
|
+
const params = new URL(this.url, 'http://test').searchParams
|
|
29
|
+
return [params.get('since'), params.get('boot')]
|
|
30
|
+
}
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
function live(): FakeEventSource[] {
|
|
29
34
|
return FakeEventSource.instances.filter(source => !source.closed)
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
async function loadRealtime() {
|
|
38
|
+
vi.resetModules()
|
|
39
|
+
return await import('../../composables/useRealtime')
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
async function loadUseRealtime() {
|
|
33
43
|
vi.resetModules()
|
|
34
44
|
return (await import('../../composables/useRealtime')).useRealtime
|
|
@@ -193,4 +203,219 @@ describe('useRealtime', () => {
|
|
|
193
203
|
|
|
194
204
|
expect([FakeEventSource.instances.length, live().length]).toEqual([1, 0])
|
|
195
205
|
})
|
|
206
|
+
|
|
207
|
+
describe('with the event cursor', () => {
|
|
208
|
+
it('should record the sequence of a createClient response', async () => {
|
|
209
|
+
const { useRealtime } = await loadRealtime()
|
|
210
|
+
const { default: axios } = await import('axios')
|
|
211
|
+
const { createPinia, setActivePinia } = await import('pinia')
|
|
212
|
+
const { createClient } = await import('../../composables/createClient')
|
|
213
|
+
setActivePinia(createPinia())
|
|
214
|
+
const adapter = axios.defaults.adapter
|
|
215
|
+
axios.defaults.adapter = async config => ({
|
|
216
|
+
data: {}, status: 200, statusText: 'OK', config,
|
|
217
|
+
headers: { 'x-mint-event-seq': '7', 'x-mint-boot-id': 'boot-a' },
|
|
218
|
+
})
|
|
219
|
+
const client = createClient(
|
|
220
|
+
{
|
|
221
|
+
schemaVersion: 2,
|
|
222
|
+
plugin: { routesPrefix: '/p', apiPrefix: '/api/p', type: 'analysis' },
|
|
223
|
+
settings: { modelName: 'Settings', valueSchema: { type: 'object', properties: {} }, formSchema: { groups: [] } },
|
|
224
|
+
endpoints: [],
|
|
225
|
+
hash: 'sha256:test',
|
|
226
|
+
},
|
|
227
|
+
{ endpoints: { health: { method: 'get', path: '/health' } } },
|
|
228
|
+
)
|
|
229
|
+
const scope = effectScope()
|
|
230
|
+
scope.run(() => useRealtime(['plugin:p'], vi.fn(), { cursor: true }))
|
|
231
|
+
|
|
232
|
+
await client.health()
|
|
233
|
+
axios.defaults.adapter = adapter
|
|
234
|
+
vi.runAllTimers()
|
|
235
|
+
|
|
236
|
+
expect(FakeEventSource.instances[0].cursor).toEqual(['7', 'boot-a'])
|
|
237
|
+
scope.stop()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('should open with since and boot and send no resync of its own when every caller opts in', async () => {
|
|
241
|
+
const { useRealtime, noteEventSeq } = await loadRealtime()
|
|
242
|
+
const handler = vi.fn()
|
|
243
|
+
const scope = effectScope()
|
|
244
|
+
scope.run(() => {
|
|
245
|
+
useRealtime(['experiments'], handler, { cursor: true })
|
|
246
|
+
useRealtime(['experiment:1'], handler, { cursor: true })
|
|
247
|
+
})
|
|
248
|
+
noteEventSeq('9', 'boot-a')
|
|
249
|
+
noteEventSeq('7', 'boot-a')
|
|
250
|
+
vi.runAllTimers()
|
|
251
|
+
FakeEventSource.instances[0].onopen?.()
|
|
252
|
+
|
|
253
|
+
expect([FakeEventSource.instances[0].cursor, handler.mock.calls]).toEqual([['7', 'boot-a'], []])
|
|
254
|
+
scope.stop()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('should keep the open resync when one caller does not opt in', async () => {
|
|
258
|
+
const { useRealtime, noteEventSeq } = await loadRealtime()
|
|
259
|
+
const handler = vi.fn()
|
|
260
|
+
const scope = effectScope()
|
|
261
|
+
scope.run(() => {
|
|
262
|
+
useRealtime(['experiments'], handler, { cursor: true })
|
|
263
|
+
useRealtime(['experiment:1'], vi.fn())
|
|
264
|
+
})
|
|
265
|
+
noteEventSeq('7', 'boot-a')
|
|
266
|
+
vi.runAllTimers()
|
|
267
|
+
FakeEventSource.instances[0].onopen?.()
|
|
268
|
+
|
|
269
|
+
expect([FakeEventSource.instances[0].cursor, handler.mock.calls]).toEqual([[null, null], [[{ type: 'resync' }]]])
|
|
270
|
+
scope.stop()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('should keep the open resync when no response recorded a cursor', async () => {
|
|
274
|
+
const { useRealtime, noteEventSeq } = await loadRealtime()
|
|
275
|
+
const handler = vi.fn()
|
|
276
|
+
const scope = effectScope()
|
|
277
|
+
scope.run(() => useRealtime(['experiments'], handler, { cursor: true }))
|
|
278
|
+
noteEventSeq(null, null)
|
|
279
|
+
vi.runAllTimers()
|
|
280
|
+
FakeEventSource.instances[0].onopen?.()
|
|
281
|
+
|
|
282
|
+
expect([FakeEventSource.instances[0].cursor, handler.mock.calls]).toEqual([[null, null], [[{ type: 'resync' }]]])
|
|
283
|
+
scope.stop()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('should deliver a resync the server sends on a cursor stream', async () => {
|
|
287
|
+
const { useRealtime, noteEventSeq } = await loadRealtime()
|
|
288
|
+
const handler = vi.fn()
|
|
289
|
+
const scope = effectScope()
|
|
290
|
+
scope.run(() => useRealtime(['experiments'], handler, { cursor: true }))
|
|
291
|
+
noteEventSeq('7', 'boot-a')
|
|
292
|
+
vi.runAllTimers()
|
|
293
|
+
FakeEventSource.instances[0].onopen?.()
|
|
294
|
+
|
|
295
|
+
live()[0].emit({ type: 'resync' })
|
|
296
|
+
|
|
297
|
+
expect(handler.mock.calls).toEqual([[{ type: 'resync' }]])
|
|
298
|
+
scope.stop()
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it.each([
|
|
302
|
+
['an older sequence', '6', 'boot-a', 1],
|
|
303
|
+
['another boot', '9', 'boot-b', 1],
|
|
304
|
+
['the same or a later sequence', '7', 'boot-a', 0],
|
|
305
|
+
])('should resync for a late response with %s', async (_label, seq, boot, resyncs) => {
|
|
306
|
+
const { useRealtime, noteEventSeq } = await loadRealtime()
|
|
307
|
+
const handler = vi.fn()
|
|
308
|
+
const scope = effectScope()
|
|
309
|
+
scope.run(() => useRealtime(['experiments'], handler, { cursor: true }))
|
|
310
|
+
noteEventSeq('7', 'boot-a')
|
|
311
|
+
vi.runAllTimers()
|
|
312
|
+
FakeEventSource.instances[0].onopen?.()
|
|
313
|
+
|
|
314
|
+
noteEventSeq(seq, boot)
|
|
315
|
+
|
|
316
|
+
expect(handler.mock.calls.length).toBe(resyncs)
|
|
317
|
+
scope.stop()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('should resync once when the responses of the refetch come from the new boot', async () => {
|
|
321
|
+
const { useRealtime, noteEventSeq } = await loadRealtime()
|
|
322
|
+
const handler = vi.fn()
|
|
323
|
+
const scope = effectScope()
|
|
324
|
+
scope.run(() => useRealtime(['experiments'], handler, { cursor: true }))
|
|
325
|
+
noteEventSeq('7', 'boot-a')
|
|
326
|
+
vi.runAllTimers()
|
|
327
|
+
FakeEventSource.instances[0].onopen?.()
|
|
328
|
+
|
|
329
|
+
noteEventSeq('0', 'boot-b')
|
|
330
|
+
noteEventSeq('0', 'boot-b')
|
|
331
|
+
|
|
332
|
+
expect(handler.mock.calls.length).toBe(1)
|
|
333
|
+
scope.stop()
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('should send a boot that never matches once responses came from two processes', async () => {
|
|
337
|
+
const { useRealtime, noteEventSeq } = await loadRealtime()
|
|
338
|
+
const scope = effectScope()
|
|
339
|
+
scope.run(() => useRealtime(['experiments'], vi.fn(), { cursor: true }))
|
|
340
|
+
noteEventSeq('2', 'boot-b')
|
|
341
|
+
noteEventSeq('5000', 'boot-a')
|
|
342
|
+
|
|
343
|
+
vi.runAllTimers()
|
|
344
|
+
|
|
345
|
+
expect(FakeEventSource.instances[0].cursor).toEqual(['2', ''])
|
|
346
|
+
scope.stop()
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('should reopen after a failure with the cursor of the first load', async () => {
|
|
350
|
+
const { useRealtime, noteEventSeq } = await loadRealtime()
|
|
351
|
+
const scope = effectScope()
|
|
352
|
+
scope.run(() => useRealtime(['experiments'], vi.fn(), { cursor: true }))
|
|
353
|
+
noteEventSeq('7', 'boot-a')
|
|
354
|
+
vi.runAllTimers()
|
|
355
|
+
FakeEventSource.instances[0].onopen?.()
|
|
356
|
+
FakeEventSource.instances[0].onerror?.()
|
|
357
|
+
noteEventSeq('9', 'boot-a')
|
|
358
|
+
|
|
359
|
+
vi.advanceTimersByTime(1000)
|
|
360
|
+
|
|
361
|
+
expect(FakeEventSource.instances[1].cursor).toEqual(['7', 'boot-a'])
|
|
362
|
+
scope.stop()
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('should reopen with the cursor of a load made while the stream connected when a topic joins', async () => {
|
|
366
|
+
const { useRealtime, noteEventSeq } = await loadRealtime()
|
|
367
|
+
const later = ref<string[]>([])
|
|
368
|
+
const scope = effectScope()
|
|
369
|
+
scope.run(() => {
|
|
370
|
+
useRealtime(['experiments'], vi.fn(), { cursor: true })
|
|
371
|
+
useRealtime(later, vi.fn(), { cursor: true })
|
|
372
|
+
})
|
|
373
|
+
noteEventSeq('3', 'boot-a')
|
|
374
|
+
vi.runAllTimers()
|
|
375
|
+
noteEventSeq('5', 'boot-a')
|
|
376
|
+
FakeEventSource.instances[0].onopen?.()
|
|
377
|
+
noteEventSeq('20', 'boot-a')
|
|
378
|
+
|
|
379
|
+
later.value = ['project:2']
|
|
380
|
+
await nextTick()
|
|
381
|
+
vi.runAllTimers()
|
|
382
|
+
|
|
383
|
+
expect(FakeEventSource.instances[1].cursor).toEqual(['3', 'boot-a'])
|
|
384
|
+
scope.stop()
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('should keep the first cursor when the topics change before the server resync arrives', async () => {
|
|
388
|
+
const { useRealtime, noteEventSeq } = await loadRealtime()
|
|
389
|
+
const topics = ref(['experiments'])
|
|
390
|
+
const scope = effectScope()
|
|
391
|
+
scope.run(() => useRealtime(topics, vi.fn(), { cursor: true }))
|
|
392
|
+
noteEventSeq('3', 'boot-a')
|
|
393
|
+
vi.runAllTimers()
|
|
394
|
+
FakeEventSource.instances[0].onopen?.()
|
|
395
|
+
noteEventSeq('9', 'boot-a')
|
|
396
|
+
|
|
397
|
+
topics.value = ['experiments', 'project:2']
|
|
398
|
+
await nextTick()
|
|
399
|
+
vi.runAllTimers()
|
|
400
|
+
|
|
401
|
+
expect(FakeEventSource.instances[1].cursor).toEqual(['3', 'boot-a'])
|
|
402
|
+
scope.stop()
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('should resync on a cursor open when a caller without the option joined while it connected', async () => {
|
|
406
|
+
const { useRealtime, noteEventSeq } = await loadRealtime()
|
|
407
|
+
const late = vi.fn()
|
|
408
|
+
const scope = effectScope()
|
|
409
|
+
scope.run(() => useRealtime(['experiments'], vi.fn(), { cursor: true }))
|
|
410
|
+
noteEventSeq('7', 'boot-a')
|
|
411
|
+
vi.runAllTimers()
|
|
412
|
+
|
|
413
|
+
scope.run(() => useRealtime(['experiments'], late))
|
|
414
|
+
vi.runAllTimers()
|
|
415
|
+
FakeEventSource.instances[0].onopen?.()
|
|
416
|
+
|
|
417
|
+
expect([FakeEventSource.instances.length, late.mock.calls]).toEqual([1, [[{ type: 'resync' }]]])
|
|
418
|
+
scope.stop()
|
|
419
|
+
})
|
|
420
|
+
})
|
|
196
421
|
})
|