@morscherlab/mint-sdk 1.1.0-beta.1 → 1.1.0-beta.2

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.
@@ -38,16 +38,24 @@ type FakeResponse = Record<string, unknown>
38
38
 
39
39
  let getResponse: FakeResponse = { plugin_name: 'drp', config: {} }
40
40
  let patchResponse: FakeResponse = { plugin_name: 'drp', config: {} }
41
+ let putResponse: FakeResponse = { plugin_name: 'drp', config: {} }
41
42
  let getError: Error | null = null
42
43
  let patchError: Error | null = null
43
- let recordedCalls: { method: string; url: string; data?: unknown }[] = []
44
+ let recordedBaseUrls: Array<string | undefined> = []
45
+ let recordedCalls: {
46
+ method: string
47
+ url: string
48
+ data?: unknown
49
+ headers?: AxiosRequestConfig['headers']
50
+ }[] = []
44
51
 
45
52
  function mockAxios() {
46
53
  vi.spyOn(axios.Axios.prototype, 'get').mockImplementation(async function (
47
54
  this: unknown,
48
55
  url: string,
49
- _config?: AxiosRequestConfig,
56
+ config?: AxiosRequestConfig,
50
57
  ) {
58
+ recordedBaseUrls.push(config?.baseURL)
51
59
  recordedCalls.push({ method: 'GET', url })
52
60
  if (getError) throw getError
53
61
  return { data: getResponse }
@@ -56,12 +64,23 @@ function mockAxios() {
56
64
  this: unknown,
57
65
  url: string,
58
66
  data?: unknown,
59
- _config?: AxiosRequestConfig,
67
+ config?: AxiosRequestConfig,
60
68
  ) {
69
+ recordedBaseUrls.push(config?.baseURL)
61
70
  recordedCalls.push({ method: 'PATCH', url, data })
62
71
  if (patchError) throw patchError
63
72
  return { data: patchResponse }
64
73
  })
74
+ vi.spyOn(axios.Axios.prototype, 'put').mockImplementation(async function (
75
+ this: unknown,
76
+ url: string,
77
+ data?: unknown,
78
+ config?: AxiosRequestConfig,
79
+ ) {
80
+ recordedBaseUrls.push(config?.baseURL)
81
+ recordedCalls.push({ method: 'PUT', url, data, headers: config?.headers })
82
+ return { data: putResponse }
83
+ })
65
84
  }
66
85
 
67
86
  describe('usePluginConfig', () => {
@@ -70,13 +89,17 @@ describe('usePluginConfig', () => {
70
89
  autoLoadOnMount = false
71
90
  getResponse = { plugin_name: 'drp', config: {} }
72
91
  patchResponse = { plugin_name: 'drp', config: {} }
92
+ putResponse = { plugin_name: 'drp', config: {} }
73
93
  getError = null
74
94
  patchError = null
75
95
  recordedCalls = []
96
+ recordedBaseUrls = []
97
+ delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
76
98
  mockAxios()
77
99
  })
78
100
 
79
101
  afterEach(() => {
102
+ delete (window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__
80
103
  vi.restoreAllMocks()
81
104
  })
82
105
 
@@ -106,12 +129,148 @@ describe('usePluginConfig', () => {
106
129
  expect(recordedCalls[0]!.url).toBe('/plugins/my%2Fplugin/config')
107
130
  })
108
131
 
109
- it('save PATCHes the plugin config endpoint', async () => {
110
- getResponse = { plugin_name: 'drp', config: { n: 1 } }
111
- patchResponse = { plugin_name: 'drp', config: { n: 2 } }
132
+ it.each([
133
+ 'platform',
134
+ undefined,
135
+ ] as const)(
136
+ 'should use the platform config facade when settings_api is %s',
137
+ async (settingsApi) => {
138
+ ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
139
+ isIntegrated: true,
140
+ theme: 'light',
141
+ platformApiUrl: '/api',
142
+ plugin: {
143
+ id: 'drp',
144
+ name: 'drp',
145
+ version: '1.0.0',
146
+ route_prefix: '/drp',
147
+ api_prefix: '/api/drp',
148
+ ...(settingsApi ? { settings_api: settingsApi } : {}),
149
+ },
150
+ }
151
+ getResponse = {
152
+ plugin_name: 'drp',
153
+ config: { n: 1 },
154
+ revision: 'revision-1',
155
+ }
156
+ const hook = usePluginConfig('drp')
157
+
158
+ await hook.load()
159
+
160
+ expect(recordedCalls).toEqual([
161
+ { method: 'GET', url: '/plugins/drp/config' },
162
+ ])
163
+ },
164
+ )
165
+
166
+ it('should use the plugin settings router for an integrated plugin capability', async () => {
167
+ ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
168
+ isIntegrated: true,
169
+ theme: 'light',
170
+ platformApiUrl: '/api',
171
+ plugin: {
172
+ id: 'drp',
173
+ name: 'drp',
174
+ version: '1.0.0',
175
+ route_prefix: '/drp',
176
+ api_prefix: '/api/drp',
177
+ settings_api: 'plugin',
178
+ },
179
+ }
180
+ getResponse = {
181
+ settings: { n: 1 },
182
+ mode: 'integrated',
183
+ revision: 'revision-1',
184
+ }
185
+ putResponse = {
186
+ settings: { n: 2 },
187
+ mode: 'integrated',
188
+ revision: 'revision-2',
189
+ }
112
190
  const hook = usePluginConfig('drp')
113
191
  await hook.load()
114
192
  hook.config.value = { n: 2 }
193
+
194
+ const saved = await hook.save()
195
+
196
+ expect({
197
+ saved,
198
+ calls: recordedCalls,
199
+ baseUrls: recordedBaseUrls,
200
+ config: hook.config.value,
201
+ }).toEqual({
202
+ saved: true,
203
+ baseUrls: ['/api/drp', '/api/drp'],
204
+ calls: [
205
+ { method: 'GET', url: '/settings' },
206
+ {
207
+ method: 'PUT',
208
+ url: '/settings',
209
+ data: { n: 2 },
210
+ headers: { 'If-Match': '"revision-1"' },
211
+ },
212
+ ],
213
+ config: { n: 2 },
214
+ })
215
+ })
216
+
217
+ it('should use the platform facade for an explicit different plugin name', async () => {
218
+ ;(window as unknown as { __MINT_PLATFORM__?: unknown }).__MINT_PLATFORM__ = {
219
+ isIntegrated: true,
220
+ theme: 'light',
221
+ platformApiUrl: '/api',
222
+ plugin: {
223
+ id: 'current-plugin',
224
+ name: 'current-plugin',
225
+ version: '1.0.0',
226
+ route_prefix: '/current-plugin',
227
+ api_prefix: '/api/current-plugin',
228
+ settings_api: 'plugin',
229
+ },
230
+ }
231
+ getResponse = {
232
+ plugin_name: 'other-plugin',
233
+ config: { n: 1 },
234
+ revision: 'revision-1',
235
+ }
236
+
237
+ const hook = usePluginConfig('other-plugin')
238
+ await hook.load()
239
+
240
+ expect({
241
+ calls: recordedCalls,
242
+ config: hook.config.value,
243
+ }).toEqual({
244
+ calls: [
245
+ { method: 'GET', url: '/plugins/other-plugin/config' },
246
+ ],
247
+ config: { n: 1 },
248
+ })
249
+ })
250
+
251
+ it('should PATCH only dirty typed fields through a versioned platform facade', async () => {
252
+ getResponse = {
253
+ plugin_name: 'drp',
254
+ config: {
255
+ n: 1,
256
+ allowed_experiment_types: ['metabolomics'],
257
+ },
258
+ revision: 'revision-1',
259
+ }
260
+ patchResponse = {
261
+ plugin_name: 'drp',
262
+ config: {
263
+ n: 2,
264
+ allowed_experiment_types: ['metabolomics'],
265
+ },
266
+ revision: 'revision-2',
267
+ }
268
+ const hook = usePluginConfig('drp')
269
+ await hook.load()
270
+ hook.config.value = {
271
+ n: 2,
272
+ allowed_experiment_types: ['metabolomics'],
273
+ }
115
274
  await nextTick()
116
275
  expect(hook.isDirty.value).toBe(true)
117
276
 
@@ -120,11 +279,168 @@ describe('usePluginConfig', () => {
120
279
  const patchCall = recordedCalls.find((c) => c.method === 'PATCH')!
121
280
  expect(patchCall.url).toBe('/plugins/drp/config')
122
281
  expect(patchCall.data).toEqual({ config: { n: 2 } })
123
- expect(hook.config.value).toEqual({ n: 2 })
282
+ expect(patchCall.headers).toBeUndefined()
283
+ expect(hook.config.value).toEqual({
284
+ n: 2,
285
+ allowed_experiment_types: ['metabolomics'],
286
+ })
124
287
  expect(hook.isDirty.value).toBe(false)
125
288
  expect(hook.lastSavedAt.value).toBeInstanceOf(Date)
126
289
  })
127
290
 
291
+ it('should split changed platform-owned config from a versioned typed save', async () => {
292
+ getResponse = {
293
+ plugin_name: 'drp',
294
+ config: {
295
+ n: 1,
296
+ allowed_experiment_types: ['metabolomics'],
297
+ },
298
+ revision: 'revision-1',
299
+ }
300
+ patchResponse = {
301
+ plugin_name: 'drp',
302
+ config: {
303
+ n: 2,
304
+ allowed_experiment_types: ['proteomics'],
305
+ },
306
+ revision: 'untrusted-platform-revision',
307
+ }
308
+ const hook = usePluginConfig('drp')
309
+ await hook.load()
310
+ hook.config.value = {
311
+ n: 2,
312
+ allowed_experiment_types: ['proteomics'],
313
+ }
314
+
315
+ const saved = await hook.save()
316
+ patchResponse = {
317
+ plugin_name: 'drp',
318
+ config: {
319
+ n: 3,
320
+ allowed_experiment_types: ['proteomics'],
321
+ },
322
+ revision: 'revision-3',
323
+ }
324
+ hook.config.value = {
325
+ n: 3,
326
+ allowed_experiment_types: ['proteomics'],
327
+ }
328
+ const savedAgain = await hook.save()
329
+
330
+ expect({
331
+ saved,
332
+ savedAgain,
333
+ calls: recordedCalls,
334
+ config: hook.config.value,
335
+ isDirty: hook.isDirty.value,
336
+ }).toEqual({
337
+ saved: true,
338
+ savedAgain: true,
339
+ calls: [
340
+ { method: 'GET', url: '/plugins/drp/config' },
341
+ {
342
+ method: 'PATCH',
343
+ url: '/plugins/drp/config',
344
+ data: { config: { n: 2 } },
345
+ },
346
+ {
347
+ method: 'PATCH',
348
+ url: '/plugins/drp/config',
349
+ data: { config: { allowed_experiment_types: ['proteomics'] } },
350
+ },
351
+ {
352
+ method: 'PATCH',
353
+ url: '/plugins/drp/config',
354
+ data: { config: { n: 3 } },
355
+ },
356
+ ],
357
+ config: {
358
+ n: 3,
359
+ allowed_experiment_types: ['proteomics'],
360
+ },
361
+ isDirty: false,
362
+ })
363
+ })
364
+
365
+ it('should avoid a typed PUT when only the platform-owned config changes', async () => {
366
+ getResponse = {
367
+ plugin_name: 'drp',
368
+ config: {
369
+ n: 1,
370
+ allowed_experiment_types: ['metabolomics'],
371
+ },
372
+ revision: 'revision-1',
373
+ }
374
+ patchResponse = {
375
+ plugin_name: 'drp',
376
+ config: {
377
+ n: 1,
378
+ allowed_experiment_types: ['proteomics'],
379
+ },
380
+ }
381
+ const hook = usePluginConfig('drp')
382
+ await hook.load()
383
+ hook.config.value = {
384
+ n: 1,
385
+ allowed_experiment_types: ['proteomics'],
386
+ }
387
+
388
+ const saved = await hook.save()
389
+
390
+ expect({
391
+ saved,
392
+ calls: recordedCalls,
393
+ config: hook.config.value,
394
+ }).toEqual({
395
+ saved: true,
396
+ calls: [
397
+ { method: 'GET', url: '/plugins/drp/config' },
398
+ {
399
+ method: 'PATCH',
400
+ url: '/plugins/drp/config',
401
+ data: { config: { allowed_experiment_types: ['proteomics'] } },
402
+ },
403
+ ],
404
+ config: {
405
+ n: 1,
406
+ allowed_experiment_types: ['proteomics'],
407
+ },
408
+ })
409
+ })
410
+
411
+ it('should detect nested config edits and restore the saved JSON snapshot', async () => {
412
+ getResponse = {
413
+ plugin_name: 'drp',
414
+ config: {
415
+ rules: {
416
+ thresholds: [1, 2],
417
+ },
418
+ },
419
+ }
420
+ const hook = usePluginConfig('drp')
421
+ await hook.load()
422
+ const rules = hook.config.value.rules as { thresholds: number[] }
423
+ rules.thresholds[0] = 9
424
+ await nextTick()
425
+
426
+ const dirtyAfterMutation = hook.isDirty.value
427
+ hook.reset()
428
+
429
+ expect({
430
+ dirtyAfterMutation,
431
+ config: hook.config.value,
432
+ isDirty: hook.isDirty.value,
433
+ }).toEqual({
434
+ dirtyAfterMutation: true,
435
+ config: {
436
+ rules: {
437
+ thresholds: [1, 2],
438
+ },
439
+ },
440
+ isDirty: false,
441
+ })
442
+ })
443
+
128
444
  it('isDirty reflects edits vs last saved snapshot', async () => {
129
445
  getResponse = { plugin_name: 'drp', config: { k: 'v' } }
130
446
  const hook = usePluginConfig('drp')
@@ -546,8 +546,14 @@ function normalizePluginSettingsOptions(options: UsePluginSettingsOptions | stri
546
546
  return typeof options === 'string' ? { pluginName: options } : options
547
547
  }
548
548
 
549
+ const PLATFORM_OWNED_SETTINGS_KEY = 'allowed_experiment_types'
550
+
549
551
  function cloneRecord(value: Record<string, unknown>): Record<string, unknown> {
550
- return { ...value }
552
+ return JSON.parse(JSON.stringify(value)) as Record<string, unknown>
553
+ }
554
+
555
+ function hasOwnSetting(settings: Record<string, unknown>, key: string): boolean {
556
+ return Object.prototype.hasOwnProperty.call(settings, key)
551
557
  }
552
558
 
553
559
  function responseSettingsPayload(response: unknown): Record<string, unknown> {
@@ -565,6 +571,43 @@ function responseSettingsPayload(response: unknown): Record<string, unknown> {
565
571
  return {}
566
572
  }
567
573
 
574
+ function responseSettingsRevision(response: unknown): string | null {
575
+ if (!response || typeof response !== 'object') return null
576
+ const revision = (response as { revision?: unknown }).revision
577
+ return typeof revision === 'string' && revision ? revision : null
578
+ }
579
+
580
+ function topLevelSettingsPatch(
581
+ current: Record<string, unknown>,
582
+ keys: Iterable<string>,
583
+ ): Record<string, unknown> {
584
+ return Object.fromEntries(
585
+ [...keys].map(key => [
586
+ key,
587
+ hasOwnSetting(current, key) ? current[key] : null,
588
+ ]),
589
+ )
590
+ }
591
+
592
+ function changedTopLevelSettingKeys(
593
+ current: Record<string, unknown>,
594
+ saved: Record<string, unknown>,
595
+ ): Set<string> {
596
+ return new Set(
597
+ [...new Set([...Object.keys(current), ...Object.keys(saved)])].filter(
598
+ key => JSON.stringify(current[key]) !== JSON.stringify(saved[key]),
599
+ ),
600
+ )
601
+ }
602
+
603
+ function platformOwnedSettingsPatch(settings: Record<string, unknown>): Record<string, unknown> {
604
+ return {
605
+ [PLATFORM_OWNED_SETTINGS_KEY]: hasOwnSetting(settings, PLATFORM_OWNED_SETTINGS_KEY)
606
+ ? settings[PLATFORM_OWNED_SETTINGS_KEY]
607
+ : null,
608
+ }
609
+ }
610
+
568
611
  /** Load, edit, and persist plugin settings from platform config or the plugin-local settings router. */
569
612
  export function usePluginSettings<TSettings = Record<string, unknown>>(
570
613
  options: UsePluginSettingsOptions | string = {},
@@ -575,8 +618,9 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
575
618
  const platformApi = useApi({ baseUrl: resolvedOptions.platformApiBaseUrl ?? injectedContext?.platformApiUrl })
576
619
  const pluginApi = useApi({ baseUrl: resolvedOptions.apiBaseUrl ?? injectedContext?.plugin?.api_prefix })
577
620
 
578
- const values = shallowRef<PluginSettingsValues<TSettings>>({} as PluginSettingsValues<TSettings>)
621
+ const values = ref({}) as Ref<PluginSettingsValues<TSettings>>
579
622
  const savedValues = shallowRef<Record<string, unknown>>({})
623
+ const revision = ref<string | null>(null)
580
624
  const request = useRequestSyncState('Plugin settings request failed.')
581
625
  const isLoading = ref(false)
582
626
  const isSaving = ref(false)
@@ -584,8 +628,17 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
584
628
  const resolvedPluginName = computed(() =>
585
629
  resolvedOptions.pluginName ?? plugin.value?.name ?? injectedContext?.plugin?.name ?? '',
586
630
  )
631
+ const usesPluginSettingsApi = computed(() => {
632
+ const currentPlugin = injectedContext?.plugin ?? plugin.value
633
+ return currentPlugin?.settings_api === 'plugin'
634
+ && resolvedPluginName.value === currentPlugin.name
635
+ })
587
636
  const usesPlatformConfig = computed(() =>
588
- Boolean((isIntegrated.value || injectedContext?.isIntegrated) && resolvedPluginName.value),
637
+ Boolean(
638
+ (isIntegrated.value || injectedContext?.isIntegrated)
639
+ && resolvedPluginName.value
640
+ && !usesPluginSettingsApi.value,
641
+ ),
589
642
  )
590
643
  const settings = computed(() => values.value)
591
644
  const isDirty = computed(() => JSON.stringify(values.value) !== JSON.stringify(savedValues.value))
@@ -600,10 +653,11 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
600
653
  values.value = cloneRecord(nextValues) as PluginSettingsValues<TSettings>
601
654
  }
602
655
 
603
- function applyLoaded(nextValues: Record<string, unknown>): void {
656
+ function applyLoaded(nextValues: Record<string, unknown>, nextRevision: string | null): void {
604
657
  const cloned = cloneRecord(nextValues)
605
658
  values.value = cloned as PluginSettingsValues<TSettings>
606
659
  savedValues.value = cloneRecord(cloned)
660
+ revision.value = nextRevision
607
661
  }
608
662
 
609
663
  async function load(): Promise<void> {
@@ -620,7 +674,10 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
620
674
  () => pluginApi.get<{ settings: Record<string, unknown>; mode?: string }>('/settings'),
621
675
  { success: 'load', errorMessage: 'Failed to load plugin settings' },
622
676
  )
623
- applyLoaded(responseSettingsPayload(response))
677
+ applyLoaded(
678
+ responseSettingsPayload(response),
679
+ responseSettingsRevision(response),
680
+ )
624
681
  } catch {
625
682
  // Error state is handled by request.run().
626
683
  } finally {
@@ -636,19 +693,65 @@ export function usePluginSettings<TSettings = Record<string, unknown>>(
636
693
  isSaving.value = true
637
694
  try {
638
695
  const payload = cloneRecord(values.value)
639
- const response = usesPlatformConfig.value
640
- ? await request.run(
641
- () => platformApi.patch<{ plugin_name: string; config: Record<string, unknown> }>(
642
- `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`,
643
- { config: payload },
644
- ),
645
- { success: 'save', errorMessage: 'Failed to save plugin settings' },
696
+ let response: unknown
697
+ if (usesPlatformConfig.value) {
698
+ const dirtyKeys = changedTopLevelSettingKeys(payload, savedValues.value)
699
+ const platformOwnedChanged = dirtyKeys.has(PLATFORM_OWNED_SETTINGS_KEY)
700
+ const typedKeys = [...dirtyKeys].filter(
701
+ key => key !== PLATFORM_OWNED_SETTINGS_KEY,
646
702
  )
647
- : await request.run(
648
- () => pluginApi.put<{ settings: Record<string, unknown>; mode?: string }>('/settings', payload),
703
+ let nextRevision = revision.value
704
+
705
+ if (typedKeys.length) {
706
+ response = await request.run(
707
+ () => platformApi.patch<{
708
+ plugin_name: string
709
+ config: Record<string, unknown>
710
+ revision?: string
711
+ }>(
712
+ `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`,
713
+ { config: topLevelSettingsPatch(payload, typedKeys) },
714
+ ),
715
+ { success: 'save', errorMessage: 'Failed to save plugin settings' },
716
+ )
717
+ nextRevision = responseSettingsRevision(response) ?? nextRevision
718
+ }
719
+
720
+ if (platformOwnedChanged) {
721
+ response = await request.run(
722
+ () => platformApi.patch<{
723
+ plugin_name: string
724
+ config: Record<string, unknown>
725
+ revision?: string
726
+ }>(
727
+ `/plugins/${encodeURIComponent(resolvedPluginName.value)}/config`,
728
+ { config: platformOwnedSettingsPatch(payload) },
729
+ ),
730
+ { success: 'save', errorMessage: 'Failed to save plugin settings' },
731
+ )
732
+ nextRevision = responseSettingsRevision(response) ?? nextRevision
733
+ }
734
+
735
+ if (!response) return true
736
+ applyLoaded(responseSettingsPayload(response), nextRevision)
737
+ return true
738
+ } else {
739
+ const ifMatch = revision.value
740
+ ? { headers: { 'If-Match': `"${revision.value}"` } }
741
+ : undefined
742
+ response = await request.run(
743
+ () => pluginApi.put<{
744
+ settings: Record<string, unknown>
745
+ mode?: string
746
+ revision?: string
747
+ }>('/settings', payload, ifMatch),
649
748
  { success: 'save', errorMessage: 'Failed to save plugin settings' },
650
749
  )
651
- applyLoaded(responseSettingsPayload(response))
750
+ }
751
+ applyLoaded(
752
+ responseSettingsPayload(response),
753
+ responseSettingsRevision(response) ?? revision.value,
754
+ )
652
755
  return true
653
756
  } catch {
654
757
  return false