@morscherlab/mint-sdk 1.5.0-beta.15 → 1.5.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/{AlertBox-Cai0w7gF.js → AlertBox-BPiQwSlr.js} +2 -2
  2. package/dist/{AlertBox-Cai0w7gF.js.map → AlertBox-BPiQwSlr.js.map} +1 -1
  3. package/dist/{AppSidebar-B1g280Wq.js → AppSidebar-avUHlogX.js} +7 -7
  4. package/dist/{AppSidebar-B1g280Wq.js.map → AppSidebar-avUHlogX.js.map} +1 -1
  5. package/dist/{ExperimentPopover-DYy_s0kI.js → ExperimentPopover-DLRfd0ab.js} +2 -2
  6. package/dist/{ExperimentPopover-DYy_s0kI.js.map → ExperimentPopover-DLRfd0ab.js.map} +1 -1
  7. package/dist/{ExperimentSelectorModal-Bfh5bdkv.js → ExperimentSelectorModal-Brsf71MN.js} +4 -4
  8. package/dist/{ExperimentSelectorModal-Bfh5bdkv.js.map → ExperimentSelectorModal-Brsf71MN.js.map} +1 -1
  9. package/dist/{useRealtime-BLQEjbuq.js → JobsStatusTray-DWd8Hpmr.js} +4 -125
  10. package/dist/JobsStatusTray-DWd8Hpmr.js.map +1 -0
  11. package/dist/{SegmentedControl-B8KgyOjd.js → SegmentedControl-CEZbeh_h.js} +2 -2
  12. package/dist/{SegmentedControl-B8KgyOjd.js.map → SegmentedControl-CEZbeh_h.js.map} +1 -1
  13. package/dist/{SettingsModal-CeaQHyot.js → SettingsModal-kBAf-THw.js} +8 -8
  14. package/dist/{SettingsModal-CeaQHyot.js.map → SettingsModal-kBAf-THw.js.map} +1 -1
  15. package/dist/components/internal/AutoGroupModalInternal.adapter.d.ts +0 -10
  16. package/dist/composables/useRealtime.d.ts +19 -3
  17. package/dist/{controlSchemaModel-BtFNZj8t.js → controlSchemaModel-CORuwx9Z.js} +2 -2
  18. package/dist/{controlSchemaModel-BtFNZj8t.js.map → controlSchemaModel-CORuwx9Z.js.map} +1 -1
  19. package/dist/{data-CJztFns8.js → data-de4y8LO7.js} +12 -11
  20. package/dist/{data-CJztFns8.js.map → data-de4y8LO7.js.map} +1 -1
  21. package/dist/data.js +7 -7
  22. package/dist/{experiment-DyO54FQB.js → experiment-CNMuOEGc.js} +6 -3
  23. package/dist/experiment-CNMuOEGc.js.map +1 -0
  24. package/dist/host.d.ts +1 -0
  25. package/dist/host.js +2 -2
  26. package/dist/host.js.map +1 -1
  27. package/dist/index.js +14 -14
  28. package/dist/{install-rcT3eXD7.js → install-C6A34A9P.js} +12 -12
  29. package/dist/{install-rcT3eXD7.js.map → install-C6A34A9P.js.map} +1 -1
  30. package/dist/install.js +1 -1
  31. package/dist/{lab-BYhb_cJI.js → lab-CGnCGPf6.js} +9 -9
  32. package/dist/lab-CGnCGPf6.js.map +1 -0
  33. package/dist/lab.js +4 -4
  34. package/dist/{records-GtiVMlTQ.js → records-TEeD11BZ.js} +2 -2
  35. package/dist/{records-GtiVMlTQ.js.map → records-TEeD11BZ.js.map} +1 -1
  36. package/dist/{runtime-7_LcVjuP.js → runtime-CmgnbmnZ.js} +12 -14
  37. package/dist/{runtime-7_LcVjuP.js.map → runtime-CmgnbmnZ.js.map} +1 -1
  38. package/dist/runtime.d.ts +1 -1
  39. package/dist/runtime.js +7 -7
  40. package/dist/{shell-DnCwE5hq.js → shell-CHecDOIP.js} +4 -4
  41. package/dist/{shell-DnCwE5hq.js.map → shell-CHecDOIP.js.map} +1 -1
  42. package/dist/shell.js +4 -4
  43. package/dist/stores/index.js +2 -2
  44. package/dist/{useExperimentSamples-CpNXvpUn.js → useExperimentSamples-s2QY9Wn_.js} +4 -4
  45. package/dist/{useExperimentSamples-CpNXvpUn.js.map → useExperimentSamples-s2QY9Wn_.js.map} +1 -1
  46. package/dist/{useListReorder-Bp4EAI2w.js → useListReorder-CNAbJi9S.js} +4 -4
  47. package/dist/{useListReorder-Bp4EAI2w.js.map → useListReorder-CNAbJi9S.js.map} +1 -1
  48. package/dist/{useRackEditor-i2bdPUFf.js → useRackEditor-ldrpkIIQ.js} +8 -8
  49. package/dist/{useRackEditor-i2bdPUFf.js.map → useRackEditor-ldrpkIIQ.js.map} +1 -1
  50. package/dist/useRealtime-t7Qg2a5h.js +216 -0
  51. package/dist/useRealtime-t7Qg2a5h.js.map +1 -0
  52. package/dist/{useRequest-CXyPw3eh.js → useRequest-CcgNnT2V.js} +2 -2
  53. package/dist/{useRequest-CXyPw3eh.js.map → useRequest-CcgNnT2V.js.map} +1 -1
  54. package/package.json +1 -1
  55. package/src/__tests__/components/AutoGroupModal.adapter.test.ts +0 -31
  56. package/src/__tests__/composables/usePresence.test.ts +21 -0
  57. package/src/__tests__/composables/useRealtime.test.ts +194 -1
  58. package/src/components/internal/AutoGroupModalInternal.adapter.ts +0 -25
  59. package/src/composables/useApi.ts +5 -1
  60. package/src/composables/usePresence.ts +4 -4
  61. package/src/composables/useRealtime.ts +59 -6
  62. package/src/composables/useVersionedSave.ts +2 -1
  63. package/src/host.ts +1 -0
  64. package/src/runtime.ts +1 -0
  65. package/dist/auth-SliLnFre.js +0 -65
  66. package/dist/auth-SliLnFre.js.map +0 -1
  67. package/dist/experiment-DyO54FQB.js.map +0 -1
  68. package/dist/lab-BYhb_cJI.js.map +0 -1
  69. 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()` or the platform's own client. Responses of\n * a `createClient` client (plain `fetch`) and of `getApiClient()` before the\n * first `useApi()` are not recorded, so a caller that loads through them must\n * 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-DyO54FQB.js";
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-CXyPw3eh.js.map
76
+ //# sourceMappingURL=useRequest-CcgNnT2V.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"useRequest-CXyPw3eh.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"}
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.15",
3
+ "version": "1.5.0-beta.16",
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,187 @@ 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 open with since and boot and send no resync of its own when every caller opts in', async () => {
209
+ const { useRealtime, noteEventSeq } = await loadRealtime()
210
+ const handler = vi.fn()
211
+ const scope = effectScope()
212
+ scope.run(() => {
213
+ useRealtime(['experiments'], handler, { cursor: true })
214
+ useRealtime(['experiment:1'], handler, { cursor: true })
215
+ })
216
+ noteEventSeq('9', 'boot-a')
217
+ noteEventSeq('7', 'boot-a')
218
+ vi.runAllTimers()
219
+ FakeEventSource.instances[0].onopen?.()
220
+
221
+ expect([FakeEventSource.instances[0].cursor, handler.mock.calls]).toEqual([['7', 'boot-a'], []])
222
+ scope.stop()
223
+ })
224
+
225
+ it('should keep the open resync when one caller does not opt in', async () => {
226
+ const { useRealtime, noteEventSeq } = await loadRealtime()
227
+ const handler = vi.fn()
228
+ const scope = effectScope()
229
+ scope.run(() => {
230
+ useRealtime(['experiments'], handler, { cursor: true })
231
+ useRealtime(['experiment:1'], vi.fn())
232
+ })
233
+ noteEventSeq('7', 'boot-a')
234
+ vi.runAllTimers()
235
+ FakeEventSource.instances[0].onopen?.()
236
+
237
+ expect([FakeEventSource.instances[0].cursor, handler.mock.calls]).toEqual([[null, null], [[{ type: 'resync' }]]])
238
+ scope.stop()
239
+ })
240
+
241
+ it('should keep the open resync when no response recorded a cursor', async () => {
242
+ const { useRealtime, noteEventSeq } = await loadRealtime()
243
+ const handler = vi.fn()
244
+ const scope = effectScope()
245
+ scope.run(() => useRealtime(['experiments'], handler, { cursor: true }))
246
+ noteEventSeq(null, null)
247
+ vi.runAllTimers()
248
+ FakeEventSource.instances[0].onopen?.()
249
+
250
+ expect([FakeEventSource.instances[0].cursor, handler.mock.calls]).toEqual([[null, null], [[{ type: 'resync' }]]])
251
+ scope.stop()
252
+ })
253
+
254
+ it('should deliver a resync the server sends on a cursor stream', async () => {
255
+ const { useRealtime, noteEventSeq } = await loadRealtime()
256
+ const handler = vi.fn()
257
+ const scope = effectScope()
258
+ scope.run(() => useRealtime(['experiments'], handler, { cursor: true }))
259
+ noteEventSeq('7', 'boot-a')
260
+ vi.runAllTimers()
261
+ FakeEventSource.instances[0].onopen?.()
262
+
263
+ live()[0].emit({ type: 'resync' })
264
+
265
+ expect(handler.mock.calls).toEqual([[{ type: 'resync' }]])
266
+ scope.stop()
267
+ })
268
+
269
+ it.each([
270
+ ['an older sequence', '6', 'boot-a', 1],
271
+ ['another boot', '9', 'boot-b', 1],
272
+ ['the same or a later sequence', '7', 'boot-a', 0],
273
+ ])('should resync for a late response with %s', async (_label, seq, boot, resyncs) => {
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('7', 'boot-a')
279
+ vi.runAllTimers()
280
+ FakeEventSource.instances[0].onopen?.()
281
+
282
+ noteEventSeq(seq, boot)
283
+
284
+ expect(handler.mock.calls.length).toBe(resyncs)
285
+ scope.stop()
286
+ })
287
+
288
+ it('should resync once when the responses of the refetch come from the new boot', async () => {
289
+ const { useRealtime, noteEventSeq } = await loadRealtime()
290
+ const handler = vi.fn()
291
+ const scope = effectScope()
292
+ scope.run(() => useRealtime(['experiments'], handler, { cursor: true }))
293
+ noteEventSeq('7', 'boot-a')
294
+ vi.runAllTimers()
295
+ FakeEventSource.instances[0].onopen?.()
296
+
297
+ noteEventSeq('0', 'boot-b')
298
+ noteEventSeq('0', 'boot-b')
299
+
300
+ expect(handler.mock.calls.length).toBe(1)
301
+ scope.stop()
302
+ })
303
+
304
+ it('should send a boot that never matches once responses came from two processes', async () => {
305
+ const { useRealtime, noteEventSeq } = await loadRealtime()
306
+ const scope = effectScope()
307
+ scope.run(() => useRealtime(['experiments'], vi.fn(), { cursor: true }))
308
+ noteEventSeq('2', 'boot-b')
309
+ noteEventSeq('5000', 'boot-a')
310
+
311
+ vi.runAllTimers()
312
+
313
+ expect(FakeEventSource.instances[0].cursor).toEqual(['2', ''])
314
+ scope.stop()
315
+ })
316
+
317
+ it('should reopen after a failure with the cursor of the first load', async () => {
318
+ const { useRealtime, noteEventSeq } = await loadRealtime()
319
+ const scope = effectScope()
320
+ scope.run(() => useRealtime(['experiments'], vi.fn(), { cursor: true }))
321
+ noteEventSeq('7', 'boot-a')
322
+ vi.runAllTimers()
323
+ FakeEventSource.instances[0].onopen?.()
324
+ FakeEventSource.instances[0].onerror?.()
325
+ noteEventSeq('9', 'boot-a')
326
+
327
+ vi.advanceTimersByTime(1000)
328
+
329
+ expect(FakeEventSource.instances[1].cursor).toEqual(['7', 'boot-a'])
330
+ scope.stop()
331
+ })
332
+
333
+ it('should reopen with the cursor of a load made while the stream connected when a topic joins', async () => {
334
+ const { useRealtime, noteEventSeq } = await loadRealtime()
335
+ const later = ref<string[]>([])
336
+ const scope = effectScope()
337
+ scope.run(() => {
338
+ useRealtime(['experiments'], vi.fn(), { cursor: true })
339
+ useRealtime(later, vi.fn(), { cursor: true })
340
+ })
341
+ noteEventSeq('3', 'boot-a')
342
+ vi.runAllTimers()
343
+ noteEventSeq('5', 'boot-a')
344
+ FakeEventSource.instances[0].onopen?.()
345
+ noteEventSeq('20', 'boot-a')
346
+
347
+ later.value = ['project:2']
348
+ await nextTick()
349
+ vi.runAllTimers()
350
+
351
+ expect(FakeEventSource.instances[1].cursor).toEqual(['3', 'boot-a'])
352
+ scope.stop()
353
+ })
354
+
355
+ it('should keep the first cursor when the topics change before the server resync arrives', async () => {
356
+ const { useRealtime, noteEventSeq } = await loadRealtime()
357
+ const topics = ref(['experiments'])
358
+ const scope = effectScope()
359
+ scope.run(() => useRealtime(topics, vi.fn(), { cursor: true }))
360
+ noteEventSeq('3', 'boot-a')
361
+ vi.runAllTimers()
362
+ FakeEventSource.instances[0].onopen?.()
363
+ noteEventSeq('9', 'boot-a')
364
+
365
+ topics.value = ['experiments', 'project:2']
366
+ await nextTick()
367
+ vi.runAllTimers()
368
+
369
+ expect(FakeEventSource.instances[1].cursor).toEqual(['3', 'boot-a'])
370
+ scope.stop()
371
+ })
372
+
373
+ it('should resync on a cursor open when a caller without the option joined while it connected', async () => {
374
+ const { useRealtime, noteEventSeq } = await loadRealtime()
375
+ const late = vi.fn()
376
+ const scope = effectScope()
377
+ scope.run(() => useRealtime(['experiments'], vi.fn(), { cursor: true }))
378
+ noteEventSeq('7', 'boot-a')
379
+ vi.runAllTimers()
380
+
381
+ scope.run(() => useRealtime(['experiments'], late))
382
+ vi.runAllTimers()
383
+ FakeEventSource.instances[0].onopen?.()
384
+
385
+ expect([FakeEventSource.instances.length, late.mock.calls]).toEqual([1, [[{ type: 'resync' }]]])
386
+ scope.stop()
387
+ })
388
+ })
196
389
  })
@@ -105,31 +105,6 @@ export function candidateColumns(columns: ColumnInfo[], groupBy: readonly number
105
105
  return columns.filter(c => GROUPING_FIELD_ROLES.has(c.role) || enabled.has(c.index))
106
106
  }
107
107
 
108
- /**
109
- * Map engine columns to Field Recipe cards + their on/off state (same order).
110
- * `layers` aligns with the cards: the column's 1-based position in the
111
- * `groupBy` layer order, or `null` when the column is not grouped by.
112
- */
113
- export function engineFieldsToCards(
114
- columns: ColumnInfo[],
115
- groupBy: readonly number[],
116
- ): { fields: SmartGroupField[]; enabled: boolean[]; layers: (number | null)[] } {
117
- const candidates = candidateColumns(columns, groupBy)
118
- return {
119
- fields: candidates.map(c => ({
120
- key: c.displayName ?? c.name,
121
- role: roleLabel(c.role),
122
- vals: previewValues(c.uniqueValues),
123
- rep: c.role === 'replicate' || c.role === 'id',
124
- })),
125
- enabled: candidates.map(c => groupBy.includes(c.index)),
126
- layers: candidates.map(c => {
127
- const pos = groupBy.indexOf(c.index)
128
- return pos === -1 ? null : pos + 1
129
- }),
130
- }
131
- }
132
-
133
108
  /**
134
109
  * Build the batch-wide field recipe from every class schema. The active schema
135
110
  * stays first for a stable card order; fields and enabled hierarchy layers that
@@ -2,6 +2,7 @@ import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosR
2
2
  import { useSettingsStore } from '../stores/settings'
3
3
  import { useAuthStore } from '../stores/auth'
4
4
  import { MintApiError, mintApiErrorFromResponse } from '../apiErrors'
5
+ import { noteEventSeq } from './useRealtime'
5
6
 
6
7
  let apiClientInstance: AxiosInstance | null = null
7
8
  let interceptorAttached = false
@@ -201,7 +202,10 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
201
202
  return config
202
203
  })
203
204
  apiClient.interceptors.response.use(
204
- (response) => response,
205
+ (response) => {
206
+ noteEventSeq(response.headers['x-mint-event-seq'] as string | undefined, response.headers['x-mint-boot-id'] as string | undefined)
207
+ return response
208
+ },
205
209
  async (error: unknown) => {
206
210
  if (axios.isAxiosError(error) && error.response) {
207
211
  const request = error.config as MintInternalAxiosRequestConfig | undefined