Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/api/providers.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const SystemProviderConfigSchema = createSelectSchema(schema.systemProvid
export const InsertSystemProviderConfigSchema = createInsertSchema(schema.systemProviderConfigs)

export const CreateProviderConfigSchema = object({
id: optional(string()),
definitionId: string(),
name: string(),
config: optional(record(string(), string())),
Expand Down
2 changes: 2 additions & 0 deletions packages/stage-ui/src/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ export * from './llm-marker-parser'
export * from './markdown'
export * from './queues'
export * from './use-analytics'
export * from './use-async-state'
export * from './use-build-info'
export * from './use-chat-session/summary'
export * from './use-optimistic'
export * from './use-scroll-to-hash'
export * from './use-versioned-local-storage'
export * from './whisper'
123 changes: 123 additions & 0 deletions packages/stage-ui/src/composables/use-optimistic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'

import { useOptimistic } from './use-optimistic'

describe('useOptimistic', () => {
it('should perform a successful optimistic update', async () => {
const state = ref('initial')
const actionResult = 'real-data'

const apply = vi.fn(() => {
const old = state.value
state.value = 'optimistic'
return () => {
state.value = old
}
})

const action = vi.fn(async () => {
return actionResult
})

const onSuccess = vi.fn((result: string) => {
state.value = `final-${result}`
return state.value
})

const { state: resultState, isLoading } = useOptimistic({
apply,
action,
onSuccess,
})

// Immediate check
expect(state.value).toBe('optimistic')
expect(apply).toHaveBeenCalled()

// Wait for action to complete
await nextTick()
await new Promise(resolve => setTimeout(resolve, 0))

expect(action).toHaveBeenCalled()
expect(onSuccess).toHaveBeenCalledWith(actionResult)
expect(state.value).toBe('final-real-data')
expect(resultState.value).toBe('final-real-data')
expect(isLoading.value).toBe(false)
})

it('should rollback on action failure', async () => {
const state = ref('initial')
const error = new Error('action failed')

const rollback = vi.fn(() => {
state.value = 'initial'
})

const apply = vi.fn(() => {
state.value = 'optimistic'
return rollback
})

const action = vi.fn(async () => {
throw error
})

const { error: errorState, isLoading } = useOptimistic({
apply,
action,
})

expect(state.value).toBe('optimistic')

// Wait for failure
await nextTick()
await new Promise(resolve => setTimeout(resolve, 0))

expect(rollback).toHaveBeenCalled()
expect(state.value).toBe('initial')
expect(errorState.value).toBe(error)
expect(isLoading.value).toBe(false)
})

it('should handle async apply and rollback', async () => {
const state = ref('initial')

const apply = async () => {
await new Promise(resolve => setTimeout(resolve, 10))
state.value = 'optimistic'
return async () => {
await new Promise(resolve => setTimeout(resolve, 10))
state.value = 'initial'
}
}

const action = async () => {
throw new Error('fail')
}

const { execute } = useOptimistic({
apply,
action,
})

await execute()

expect(state.value).toBe('initial')
})

it('should not throw if apply returns non-function', async () => {
const action = vi.fn(async () => {
throw new Error('fail')
})

const { execute, error } = useOptimistic({
// @ts-expect-error - testing invalid return
apply: () => null,
action,
})

await execute()
expect(error.value).toBeDefined()
})
})
50 changes: 50 additions & 0 deletions packages/stage-ui/src/composables/use-optimistic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useAsyncState } from './use-async-state'

export interface UseOptimisticOptions<T, R> {
/**
* The optimistic update logic.
* Should return a rollback function.
*/
apply: () => Promise<(() => Promise<void> | void)> | (() => Promise<void> | void)
/**
* The actual async task (e.g., API call).
*/
action: () => Promise<T>
/**
* Optional callback after successful action to refine state (e.g., replacing temp IDs).
*/
onSuccess?: (result: T) => Promise<R> | R
/**
* Optional callback on error. Rollback is handled automatically.
*/
onError?: (error: unknown) => void | Promise<void>
}

/**
* A wrapper for performing optimistic updates with automatic rollback.
* Integrates with useAsyncState for loading/error tracking.
*/
export function useOptimistic<T, R = void>(options: UseOptimisticOptions<T, R>) {
const { apply, action, onSuccess, onError } = options

return useAsyncState(async () => {
const rollback = await apply()

try {
const result = await action()
if (onSuccess) {
return await onSuccess(result)
}
return result as unknown as R
}
catch (err) {
if (typeof rollback === 'function') {
await rollback()
}
if (onError) {
await onError(err)
}
throw err
}
}, { immediate: true })
}
26 changes: 25 additions & 1 deletion packages/stage-ui/src/stores/provider-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,33 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { providerOpenAICompatible } from '../libs/providers/providers/openai-compatible'
import { useProviderCatalogStore } from './provider-catalog'

vi.mock('../database/repos/providers.repo', () => ({
providersRepo: {
getAll: vi.fn(async () => ({})),
saveAll: vi.fn(async () => {}),
upsert: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
},
}))

vi.mock('../composables/api', () => ({
client: {
api: {
providers: {
'$get': vi.fn(async () => ({ ok: true, json: async () => [] })),
'$post': vi.fn(async () => ({ ok: true, json: async () => ({ id: 'real-id', definitionId: 'openai-compatible', name: 'OpenAI Compatible', config: {}, validated: false, validationBypassed: false }) })),
':id': {
$delete: vi.fn(async () => ({ ok: true })),
$patch: vi.fn(async () => ({ ok: true, json: async () => ({}) })),
},
},
},
},
}))

describe('store provider-catalog', () => {
beforeEach(() => {
// creates a fresh pinia and makes it active
Expand Down
Loading