feat: Implement PartyAdapter with comprehensive functionality

- Add PartyAdapter for party CRUD and grid management
- Create reactive PartyResource with optimistic updates
- Support user parties listing with filters
- Handle grid conflicts and job updates
- Include comprehensive test coverage
This commit is contained in:
Justin Edmund 2025-09-19 23:19:24 -07:00
parent 20c6de3834
commit 114427241f
7 changed files with 1326 additions and 21 deletions

View file

@ -0,0 +1,508 @@
/**
* Tests for PartyAdapter
*
* These tests verify party CRUD operations, grid management,
* and conflict resolution functionality.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { PartyAdapter } from '../party.adapter'
import type { Party, GridWeapon, GridSummon, GridCharacter } from '../party.adapter'
describe('PartyAdapter', () => {
let adapter: PartyAdapter
let originalFetch: typeof global.fetch
const mockParty: Party = {
id: '123',
shortcode: 'ABC123',
name: 'Test Party',
description: 'Test description',
visibility: 'public',
user: {
id: 'user-1',
username: 'testuser'
},
job: {
id: 'job-1',
name: { en: 'Warrior' },
skills: [
{
id: 'skill-1',
name: { en: 'Rage' },
slot: 1
}
]
},
raid: {
id: 'raid-1',
name: { en: 'Proto Bahamut' },
group: {
id: 'group-1',
name: { en: 'Tier 1' }
}
},
gridWeapons: [],
gridSummons: [],
gridCharacters: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z'
}
beforeEach(() => {
originalFetch = global.fetch
adapter = new PartyAdapter({ baseURL: 'https://api.example.com' })
})
afterEach(() => {
global.fetch = originalFetch
vi.clearAllTimers()
})
describe('CRUD operations', () => {
it('should create a new party', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockParty
})
const result = await adapter.create({
name: 'Test Party',
description: 'Test description',
visibility: 'public'
})
expect(result).toEqual(mockParty)
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/parties',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
party: {
name: 'Test Party',
description: 'Test description',
visibility: 'public'
}
})
})
)
})
it('should get a party by shortcode', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockParty
})
const result = await adapter.getByShortcode('ABC123')
expect(result).toEqual(mockParty)
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/parties/ABC123',
expect.any(Object)
)
})
it('should update a party', async () => {
const updatedParty = { ...mockParty, name: 'Updated Party' }
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => updatedParty
})
const result = await adapter.update({
shortcode: 'ABC123',
name: 'Updated Party'
})
expect(result).toEqual(updatedParty)
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/parties/ABC123',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({
party: { name: 'Updated Party' }
})
})
)
})
it('should delete a party', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({})
})
await adapter.delete('ABC123')
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/parties/ABC123',
expect.objectContaining({
method: 'DELETE'
})
)
})
it('should remix a party', async () => {
const remixedParty = { ...mockParty, id: '456', shortcode: 'DEF456' }
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => remixedParty
})
const result = await adapter.remix('ABC123')
expect(result).toEqual(remixedParty)
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/parties/ABC123/remix',
expect.objectContaining({
method: 'POST'
})
)
})
})
describe('user parties listing', () => {
it('should list user parties with filters', async () => {
const mockResponse = {
results: [mockParty],
total: 1,
page: 1,
totalPages: 1
}
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockResponse
})
const result = await adapter.listUserParties({
username: 'testuser',
page: 1,
per: 20,
visibility: 'public',
raidId: 'raid-1'
})
expect(result).toEqual(mockResponse)
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/users/testuser/parties'),
expect.objectContaining({
method: 'GET'
})
)
// Verify query parameters were included
const callUrl = (global.fetch as any).mock.calls[0][0]
expect(callUrl).toContain('page=1')
expect(callUrl).toContain('per=20')
expect(callUrl).toContain('visibility=public')
expect(callUrl).toContain('raid_id=raid-1')
})
})
describe('grid management', () => {
it('should update grid weapons', async () => {
const mockGridWeapons: GridWeapon[] = [
{
id: 'gw-1',
position: 1,
mainhand: true,
uncapLevel: 5,
transcendenceStage: 0,
weaponKeys: [],
weapon: {
id: 'weapon-1',
granblueId: 'w-1',
name: { en: 'Sword' },
element: 1,
rarity: 5
}
}
]
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
grid_weapons: mockGridWeapons.map(gw => ({
...gw,
uncap_level: gw.uncapLevel,
transcendence_stage: gw.transcendenceStage,
weapon_keys: gw.weaponKeys
}))
})
})
const result = await adapter.updateGridWeapons({
shortcode: 'ABC123',
updates: [
{
position: 1,
weaponId: 'weapon-1',
mainhand: true,
uncapLevel: 5
}
]
})
expect(result.gridWeapons).toEqual(mockGridWeapons)
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/parties/ABC123/grid_weapons',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({
grid_weapons: [
{
position: 1,
weapon_id: 'weapon-1',
mainhand: true,
uncap_level: 5
}
]
})
})
)
})
it('should update grid summons', async () => {
const mockGridSummons: GridSummon[] = [
{
id: 'gs-1',
position: 1,
quickSummon: true,
transcendenceStage: 2,
summon: {
id: 'summon-1',
granblueId: 's-1',
name: { en: 'Bahamut' },
element: 6,
rarity: 5
}
}
]
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
grid_summons: mockGridSummons.map(gs => ({
...gs,
quick_summon: gs.quickSummon,
transcendence_stage: gs.transcendenceStage
}))
})
})
const result = await adapter.updateGridSummons({
shortcode: 'ABC123',
updates: [
{
position: 1,
summonId: 'summon-1',
quickSummon: true,
transcendenceStage: 2
}
]
})
expect(result.gridSummons).toEqual(mockGridSummons)
})
it('should update grid characters', async () => {
const mockGridCharacters: GridCharacter[] = [
{
id: 'gc-1',
position: 1,
uncapLevel: 5,
transcendenceStage: 1,
character: {
id: 'char-1',
granblueId: 'c-1',
name: { en: 'Katalina' },
element: 2,
rarity: 5
}
}
]
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
grid_characters: mockGridCharacters.map(gc => ({
...gc,
uncap_level: gc.uncapLevel,
transcendence_stage: gc.transcendenceStage
}))
})
})
const result = await adapter.updateGridCharacters({
shortcode: 'ABC123',
updates: [
{
position: 1,
characterId: 'char-1',
uncapLevel: 5,
transcendenceStage: 1
}
]
})
expect(result.gridCharacters).toEqual(mockGridCharacters)
})
it('should handle grid conflicts', async () => {
const conflictResponse = {
grid_weapons: [],
conflicts: {
conflicts: [
{
type: 'weapon',
position: 1,
existing: { id: 'weapon-1' },
new: { id: 'weapon-2' }
}
],
resolved: false
}
}
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => conflictResponse
})
const result = await adapter.updateGridWeapons({
shortcode: 'ABC123',
updates: [
{
position: 1,
weaponId: 'weapon-2'
}
]
})
expect(result.conflicts).toBeDefined()
expect(result.conflicts?.resolved).toBe(false)
expect(result.conflicts?.conflicts).toHaveLength(1)
})
})
describe('job management', () => {
it('should update party job', async () => {
const updatedParty = {
...mockParty,
job: {
id: 'job-2',
name: { en: 'Mage' },
skills: [
{
id: 'skill-2',
name: { en: 'Fireball' },
slot: 1
}
],
accessory: {
id: 'acc-1',
name: { en: 'Magic Ring' }
}
}
}
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => updatedParty
})
const result = await adapter.updateJob(
'ABC123',
'job-2',
[{ id: 'skill-2', slot: 1 }],
'acc-1'
)
expect(result).toEqual(updatedParty)
expect(global.fetch).toHaveBeenCalledWith(
'https://api.example.com/parties/ABC123',
expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({
party: {
job_id: 'job-2',
job_skills_attributes: [{ id: 'skill-2', slot: 1 }],
job_accessory_id: 'acc-1'
}
})
})
)
})
})
describe('cache management', () => {
it('should cache party retrieval', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockParty
})
// First call
await adapter.getByShortcode('ABC123')
// Second call (should use cache)
await adapter.getByShortcode('ABC123')
// Should only call fetch once due to caching
expect(global.fetch).toHaveBeenCalledTimes(1)
})
it('should clear party cache', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => mockParty
})
// First call
await adapter.getByShortcode('ABC123')
// Clear cache
adapter.clearPartyCache('ABC123')
// Second call (should not use cache)
await adapter.getByShortcode('ABC123')
// Should call fetch twice since cache was cleared
expect(global.fetch).toHaveBeenCalledTimes(2)
})
})
describe('error handling', () => {
it('should handle 404 errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
json: async () => ({ error: 'Party not found' })
})
await expect(adapter.getByShortcode('INVALID')).rejects.toThrow()
})
it('should handle validation errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 422,
statusText: 'Unprocessable Entity',
json: async () => ({
errors: {
name: ['is too long']
}
})
})
await expect(
adapter.create({
name: 'A'.repeat(256)
})
).rejects.toThrow()
})
})
})

View file

@ -87,14 +87,14 @@ export abstract class BaseAdapter {
path: string,
options: RequestOptions = {}
): Promise<T> {
// Build the full URL with query parameters
const url = this.buildURL(path, options.params)
// Build the full URL with query parameters (support both params and query)
const url = this.buildURL(path, options.query || options.params)
// Generate a unique ID for this request (used for cancellation and caching)
const requestId = this.generateRequestId(path, options.method, options.body as string)
// Check cache first if caching is enabled
const cacheTime = options.cache ?? this.options.cacheTime
// Check cache first if caching is enabled (support both cache and cacheTTL)
const cacheTime = options.cacheTTL ?? options.cache ?? this.options.cacheTime
// Allow caching for any method if explicitly set
if (cacheTime > 0) {
const cached = this.getFromCache(requestId)
@ -122,13 +122,18 @@ export abstract class BaseAdapter {
}
// Transform request body from camelCase to snake_case if present
if (options.body && typeof options.body === 'string') {
try {
const bodyData = JSON.parse(options.body)
fetchOptions.body = JSON.stringify(this.transformRequest(bodyData))
} catch {
// If body is not valid JSON, use as-is
fetchOptions.body = options.body
if (options.body) {
if (typeof options.body === 'object') {
// Body is an object, transform and stringify
fetchOptions.body = JSON.stringify(this.transformRequest(options.body))
} else if (typeof options.body === 'string') {
try {
const bodyData = JSON.parse(options.body)
fetchOptions.body = JSON.stringify(this.transformRequest(bodyData))
} catch {
// If body is not valid JSON, use as-is
fetchOptions.body = options.body
}
}
}
@ -140,7 +145,7 @@ export abstract class BaseAdapter {
const data = await response.json()
const transformed = this.transformResponse<T>(data)
// Cache the successful response if caching is enabled
// Cache the successful response if caching is enabled (use cacheTTL or cache)
if (cacheTime > 0) {
this.setCache(requestId, transformed, cacheTime)
}
@ -348,7 +353,10 @@ export abstract class BaseAdapter {
private addQueryParams(url: URL, params?: Record<string, any>): void {
if (!params) return
Object.entries(params).forEach(([key, value]) => {
// Transform query parameters from camelCase to snake_case
const transformed = this.transformRequest(params)
Object.entries(transformed).forEach(([key, value]) => {
// Skip undefined and null values
if (value === undefined || value === null) return

View file

@ -15,8 +15,25 @@ export * from './errors'
// Resource-specific adapters
export { SearchAdapter, searchAdapter } from './search.adapter'
export type { SearchParams, SearchResult, SearchResponse } from './search.adapter'
// export { PartyAdapter } from './party.adapter'
export { PartyAdapter, partyAdapter } from './party.adapter'
export type {
Party,
GridWeapon,
GridSummon,
GridCharacter,
CreatePartyParams,
UpdatePartyParams,
ListUserPartiesParams,
UpdateGridParams,
GridWeaponUpdate,
GridSummonUpdate,
GridCharacterUpdate,
ConflictResolution
} from './party.adapter'
// export { GridAdapter } from './grid.adapter'
// export { EntityAdapter } from './entity.adapter'
// Reactive resources using Svelte 5 runes
export * from './resources'

View file

@ -0,0 +1,381 @@
/**
* Party Adapter
*
* Handles all party-related API operations including CRUD, grids, and remixing.
* Provides a clean interface for party management with automatic
* request handling, caching, and error management.
*
* @module adapters/party
*/
import { BaseAdapter } from './base.adapter'
import type { RequestOptions, AdapterOptions, PaginatedResponse } from './types'
/**
* Party data structure
*/
export interface Party {
id: string
shortcode: string
name?: string
description?: string
visibility: 'public' | 'private' | 'unlisted'
user: {
id: string
username: string
}
job?: {
id: string
name: Record<string, string>
skills: Array<{
id: string
name: Record<string, string>
slot: number
}>
accessory?: {
id: string
name: Record<string, string>
}
}
raid?: {
id: string
name: Record<string, string>
group?: {
id: string
name: Record<string, string>
}
}
gridWeapons: GridWeapon[]
gridSummons: GridSummon[]
gridCharacters: GridCharacter[]
guidebook?: {
id: string
title: string
}
extras?: Record<string, any>
createdAt: string
updatedAt: string
}
/**
* Grid weapon structure
*/
export interface GridWeapon {
id: string
position: number
mainhand: boolean
uncapLevel: number
transcendenceStage: number
weaponKeys: Array<{
id: string
slot: number
}>
weapon: {
id: string
granblueId: string
name: Record<string, string>
element: number
rarity: number
}
}
/**
* Grid summon structure
*/
export interface GridSummon {
id: string
position: number
quickSummon: boolean
transcendenceStage: number
summon: {
id: string
granblueId: string
name: Record<string, string>
element: number
rarity: number
}
}
/**
* Grid character structure
*/
export interface GridCharacter {
id: string
position: number
uncapLevel: number
transcendenceStage: number
perpetualModifiers?: Record<string, any>
awakenings?: Array<{
id: string
level: number
}>
character: {
id: string
granblueId: string
name: Record<string, string>
element: number
rarity: number
}
}
/**
* Parameters for creating a new party
*/
export interface CreatePartyParams {
name?: string
description?: string
visibility?: 'public' | 'private' | 'unlisted'
jobId?: string
raidId?: string
guidebookId?: string
extras?: Record<string, any>
}
/**
* Parameters for updating a party
*/
export interface UpdatePartyParams extends CreatePartyParams {
shortcode: string
}
/**
* Parameters for listing user parties
*/
export interface ListUserPartiesParams {
username: string
page?: number
per?: number
visibility?: 'public' | 'private' | 'unlisted' | 'all'
raidId?: string
characterId?: string
weaponId?: string
summonId?: string
}
/**
* Parameters for updating grid items
*/
export interface UpdateGridParams<T> {
shortcode: string
updates: T[]
}
/**
* Grid weapon update structure
*/
export interface GridWeaponUpdate {
id?: string
position: number
weaponId: string
mainhand?: boolean
uncapLevel?: number
transcendenceStage?: number
weaponKeys?: Array<{
id: string
slot: number
}>
_destroy?: boolean
}
/**
* Grid summon update structure
*/
export interface GridSummonUpdate {
id?: string
position: number
summonId: string
quickSummon?: boolean
transcendenceStage?: number
_destroy?: boolean
}
/**
* Grid character update structure
*/
export interface GridCharacterUpdate {
id?: string
position: number
characterId: string
uncapLevel?: number
transcendenceStage?: number
perpetualModifiers?: Record<string, any>
awakenings?: Array<{
id: string
level: number
}>
_destroy?: boolean
}
/**
* Conflict resolution result
*/
export interface ConflictResolution {
conflicts: Array<{
type: 'weapon' | 'summon' | 'character'
position: number
existing: any
new: any
}>
resolved: boolean
}
/**
* Party adapter for managing parties and their grids
*/
export class PartyAdapter extends BaseAdapter {
constructor(options?: AdapterOptions) {
super({
...options,
baseURL: options?.baseURL || '/api/v1'
})
}
/**
* Creates a new party
*/
async create(params: CreatePartyParams): Promise<Party> {
return this.request<Party>('/parties', {
method: 'POST',
body: {
party: params
}
})
}
/**
* Gets a party by shortcode
*/
async getByShortcode(shortcode: string): Promise<Party> {
return this.request<Party>(`/parties/${shortcode}`, {
cacheTTL: 60000 // Cache for 1 minute
})
}
/**
* Updates a party
*/
async update(params: UpdatePartyParams): Promise<Party> {
const { shortcode, ...updateParams } = params
return this.request<Party>(`/parties/${shortcode}`, {
method: 'PATCH',
body: {
party: updateParams
}
})
}
/**
* Deletes a party
*/
async delete(shortcode: string): Promise<void> {
return this.request<void>(`/parties/${shortcode}`, {
method: 'DELETE'
})
}
/**
* Creates a remix (copy) of an existing party
*/
async remix(shortcode: string): Promise<Party> {
return this.request<Party>(`/parties/${shortcode}/remix`, {
method: 'POST'
})
}
/**
* Lists parties for a specific user
*/
async listUserParties(params: ListUserPartiesParams): Promise<PaginatedResponse<Party>> {
const { username, ...queryParams } = params
return this.request<PaginatedResponse<Party>>(`/users/${username}/parties`, {
method: 'GET',
query: queryParams,
cacheTTL: 30000 // Cache for 30 seconds
})
}
/**
* Updates grid weapons for a party
*/
async updateGridWeapons(
params: UpdateGridParams<GridWeaponUpdate>
): Promise<{ gridWeapons: GridWeapon[]; conflicts?: ConflictResolution }> {
const { shortcode, updates } = params
return this.request(`/parties/${shortcode}/grid_weapons`, {
method: 'PATCH',
body: {
grid_weapons: updates
}
})
}
/**
* Updates grid summons for a party
*/
async updateGridSummons(
params: UpdateGridParams<GridSummonUpdate>
): Promise<{ gridSummons: GridSummon[]; conflicts?: ConflictResolution }> {
const { shortcode, updates } = params
return this.request(`/parties/${shortcode}/grid_summons`, {
method: 'PATCH',
body: {
grid_summons: updates
}
})
}
/**
* Updates grid characters for a party
*/
async updateGridCharacters(
params: UpdateGridParams<GridCharacterUpdate>
): Promise<{ gridCharacters: GridCharacter[]; conflicts?: ConflictResolution }> {
const { shortcode, updates } = params
return this.request(`/parties/${shortcode}/grid_characters`, {
method: 'PATCH',
body: {
grid_characters: updates
}
})
}
/**
* Updates the job for a party
*/
async updateJob(
shortcode: string,
jobId: string,
skills?: Array<{ id: string; slot: number }>,
accessoryId?: string
): Promise<Party> {
return this.request<Party>(`/parties/${shortcode}`, {
method: 'PATCH',
body: {
party: {
job_id: jobId,
...(skills && { job_skills_attributes: skills }),
...(accessoryId && { job_accessory_id: accessoryId })
}
}
})
}
/**
* Clears the cache for party-related data
*/
clearPartyCache(shortcode?: string) {
if (shortcode) {
// Clear specific party cache
this.clearCache(`/parties/${shortcode}`)
} else {
// Clear all party and user caches
this.clearCache('/parties')
this.clearCache('/users')
}
}
}
/**
* Default party adapter instance
*/
export const partyAdapter = new PartyAdapter()

View file

@ -10,6 +10,9 @@
export { SearchResource, createSearchResource } from './search.resource.svelte'
export type { SearchResourceOptions } from './search.resource.svelte'
export { PartyResource, createPartyResource } from './party.resource.svelte'
export type { PartyResourceOptions } from './party.resource.svelte'
// Future resources will be added here
// export { PartyResource, createPartyResource } from './party.resource.svelte'
// export { GridResource, createGridResource } from './grid.resource.svelte'
// export { GridResource, createGridResource } from './grid.resource.svelte'
// export { EntityResource, createEntityResource } from './entity.resource.svelte'

View file

@ -0,0 +1,379 @@
/**
* Reactive Party Resource using Svelte 5 Runes
*
* Provides reactive state management for party operations with
* automatic loading states, error handling, and optimistic updates.
*
* @module adapters/resources/party
*/
import { PartyAdapter, type Party, type CreatePartyParams, type UpdatePartyParams } from '../party.adapter'
import type { AdapterError } from '../types'
/**
* Party resource configuration options
*/
export interface PartyResourceOptions {
/** Party adapter instance to use */
adapter?: PartyAdapter
/** Enable optimistic updates for mutations */
optimistic?: boolean
}
/**
* Resource state for a single party
*/
interface PartyState {
data?: Party
loading: boolean
error?: AdapterError
updating?: boolean
}
/**
* Resource state for party lists
*/
interface PartyListState {
parties: Party[]
total?: number
page?: number
totalPages?: number
loading: boolean
error?: AdapterError
}
/**
* Creates a reactive party resource for managing parties
*
* @example
* ```svelte
* <script>
* import { createPartyResource } from '$lib/api/adapters/resources'
*
* const party = createPartyResource()
*
* // Load a party
* party.load('ABC123')
*
* // Update party details
* party.update({
* shortcode: 'ABC123',
* name: 'New Name'
* })
* </script>
*
* {#if party.current.loading}
* <p>Loading party...</p>
* {:else if party.current.error}
* <p>Error: {party.current.error.message}</p>
* {:else if party.current.data}
* <h1>{party.current.data.name}</h1>
* {/if}
* ```
*/
export class PartyResource {
private adapter: PartyAdapter
private optimistic: boolean
// Reactive state for current party
current = $state<PartyState>({ loading: false })
// Reactive state for user parties list
userParties = $state<PartyListState>({
parties: [],
loading: false
})
// Track active requests for cancellation
private activeRequests = new Map<string, AbortController>()
constructor(options: PartyResourceOptions = {}) {
this.adapter = options.adapter || new PartyAdapter()
this.optimistic = options.optimistic ?? true
}
/**
* Loads a party by shortcode
*/
async load(shortcode: string): Promise<Party | undefined> {
// Cancel any existing load request
this.cancelRequest('load')
const controller = new AbortController()
this.activeRequests.set('load', controller)
this.current = { ...this.current, loading: true, error: undefined }
try {
const party = await this.adapter.getByShortcode(shortcode)
this.current = { data: party, loading: false }
return party
} catch (error: any) {
if (error.code !== 'CANCELLED') {
this.current = {
...this.current,
loading: false,
error: error as AdapterError
}
}
} finally {
this.activeRequests.delete('load')
}
}
/**
* Creates a new party
*/
async create(params: CreatePartyParams): Promise<Party | undefined> {
this.current = { ...this.current, updating: true, error: undefined }
try {
const party = await this.adapter.create(params)
this.current = { data: party, loading: false, updating: false }
// Add to user parties if loaded
if (this.userParties.parties.length > 0) {
this.userParties.parties = [party, ...this.userParties.parties]
if (this.userParties.total !== undefined) {
this.userParties.total++
}
}
return party
} catch (error: any) {
this.current = {
...this.current,
updating: false,
error: error as AdapterError
}
}
}
/**
* Updates the current party
*/
async update(params: UpdatePartyParams): Promise<Party | undefined> {
// Optimistic update
if (this.optimistic && this.current.data) {
const optimisticData = {
...this.current.data,
...params,
updatedAt: new Date().toISOString()
}
this.current = {
...this.current,
data: optimisticData as Party,
updating: true
}
} else {
this.current = { ...this.current, updating: true }
}
try {
const party = await this.adapter.update(params)
this.current = { data: party, loading: false, updating: false }
// Update in user parties list if present
const index = this.userParties.parties.findIndex(
p => p.shortcode === params.shortcode
)
if (index !== -1) {
this.userParties.parties[index] = party
}
return party
} catch (error: any) {
// Revert optimistic update on error
if (this.optimistic) {
await this.load(params.shortcode)
}
this.current = {
...this.current,
updating: false,
error: error as AdapterError
}
}
}
/**
* Deletes the current party
*/
async delete(shortcode: string): Promise<boolean> {
this.current = { ...this.current, updating: true, error: undefined }
try {
await this.adapter.delete(shortcode)
// Clear current party
this.current = { loading: false, updating: false }
// Remove from user parties list
this.userParties.parties = this.userParties.parties.filter(
p => p.shortcode !== shortcode
)
if (this.userParties.total !== undefined && this.userParties.total > 0) {
this.userParties.total--
}
return true
} catch (error: any) {
this.current = {
...this.current,
updating: false,
error: error as AdapterError
}
return false
}
}
/**
* Creates a remix (copy) of a party
*/
async remix(shortcode: string): Promise<Party | undefined> {
this.current = { ...this.current, updating: true, error: undefined }
try {
const party = await this.adapter.remix(shortcode)
this.current = { data: party, loading: false, updating: false }
// Add to user parties if it's the current user's remix
if (this.userParties.parties.length > 0) {
this.userParties.parties = [party, ...this.userParties.parties]
if (this.userParties.total !== undefined) {
this.userParties.total++
}
}
return party
} catch (error: any) {
this.current = {
...this.current,
updating: false,
error: error as AdapterError
}
}
}
/**
* Loads parties for a specific user
*/
async loadUserParties(
username: string,
params: Omit<Parameters<PartyAdapter['listUserParties']>[0], 'username'> = {}
): Promise<void> {
// Cancel any existing user parties request
this.cancelRequest('userParties')
const controller = new AbortController()
this.activeRequests.set('userParties', controller)
this.userParties = { ...this.userParties, loading: true, error: undefined }
try {
const response = await this.adapter.listUserParties({
username,
...params
})
this.userParties = {
parties: response.results,
total: response.total,
page: response.page,
totalPages: response.totalPages,
loading: false
}
} catch (error: any) {
if (error.code !== 'CANCELLED') {
this.userParties = {
...this.userParties,
loading: false,
error: error as AdapterError
}
}
} finally {
this.activeRequests.delete('userParties')
}
}
/**
* Updates the job for the current party
*/
async updateJob(
shortcode: string,
jobId: string,
skills?: Array<{ id: string; slot: number }>,
accessoryId?: string
): Promise<Party | undefined> {
this.current = { ...this.current, updating: true, error: undefined }
try {
const party = await this.adapter.updateJob(shortcode, jobId, skills, accessoryId)
this.current = { data: party, loading: false, updating: false }
return party
} catch (error: any) {
this.current = {
...this.current,
updating: false,
error: error as AdapterError
}
}
}
/**
* Cancels an active request
*/
private cancelRequest(key: string) {
const controller = this.activeRequests.get(key)
if (controller) {
controller.abort()
this.activeRequests.delete(key)
}
}
/**
* Cancels all active requests
*/
cancelAll() {
this.activeRequests.forEach(controller => controller.abort())
this.activeRequests.clear()
}
/**
* Clears the current party state
*/
clearCurrent() {
this.cancelRequest('load')
this.current = { loading: false }
}
/**
* Clears the user parties state
*/
clearUserParties() {
this.cancelRequest('userParties')
this.userParties = { parties: [], loading: false }
}
/**
* Clears all states
*/
clearAll() {
this.cancelAll()
this.current = { loading: false }
this.userParties = { parties: [], loading: false }
}
/**
* Clears the adapter's cache
*/
clearCache(shortcode?: string) {
this.adapter.clearPartyCache(shortcode)
}
}
/**
* Factory function for creating party resources
*/
export function createPartyResource(options?: PartyResourceOptions): PartyResource {
return new PartyResource(options)
}

View file

@ -36,15 +36,21 @@ export interface RequestOptions extends Omit<RequestInit, 'body'> {
/** Query parameters to append to the URL */
params?: Record<string, any>
/** Alternative alias for query parameters */
query?: Record<string, any>
/** Request timeout in milliseconds. Overrides the adapter's default timeout */
timeout?: number
/** Number of retry attempts for this specific request */
retries?: number
/** Cache duration for this request in milliseconds. Only applies to GET requests */
/** Cache duration for this request in milliseconds */
cache?: number
/** Alternative alias for cache duration */
cacheTTL?: number
/** Request body. Can be any serializable value */
body?: any
}
@ -75,8 +81,11 @@ export interface AdapterError {
* Used for endpoints that return paginated data
*/
export interface PaginatedResponse<T> {
/** Array of items for the current page */
items: T[]
/** Array of items for the current page (can be 'results' or 'items') */
results: T[]
/** Alternative key for items */
items?: T[]
/** Total number of items across all pages */
total: number
@ -88,10 +97,10 @@ export interface PaginatedResponse<T> {
totalPages: number
/** Number of items per page */
perPage: number
perPage?: number
/** Whether there are more pages available */
hasMore: boolean
hasMore?: boolean
/** Cursor or page number for the next page, if available */
nextCursor?: string | number