From 56c55e18133c7c5885686bc56a2acc934c47925b Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 11 May 2026 13:43:32 +0200 Subject: [PATCH] feat(PBI-79/ST-1334): user-settings pendingSprintDraft-slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/user-settings.ts: nieuw workflow.pendingSprintDraft veld met compacte intent-shape (pbiIntent + per-PBI storyOverrides). - actions/sprint-draft.ts: setPendingSprintDraftAction + clearPendingSprintDraftAction met product-membership-check + Zod-validatie. - stores/user-settings/store.ts: setPendingSprintDraft / clearPendingSprintDraft optimistic acties + fine-grained mutators upsertPbiIntent / upsertStoryOverride. Sprint-draft actions worden dynamisch geïmporteerd zodat jsdom-tests zonder DATABASE_URL niet falen. - Tests: nieuwe sprint-draft.test.ts (action-laag), uitbreiding user-settings store-tests (5 nieuwe cases) en schema-tests (4 cases). Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/actions/sprint-draft.test.ts | 167 ++++++++++++++++++++++++ __tests__/lib/user-settings.test.ts | 48 +++++++ __tests__/stores/user-settings.test.ts | 169 +++++++++++++++++++++++++ actions/sprint-draft.ts | 121 ++++++++++++++++++ lib/user-settings.ts | 23 ++++ stores/user-settings/store.ts | 108 ++++++++++++++++ 6 files changed, 636 insertions(+) create mode 100644 __tests__/actions/sprint-draft.test.ts create mode 100644 actions/sprint-draft.ts diff --git a/__tests__/actions/sprint-draft.test.ts b/__tests__/actions/sprint-draft.test.ts new file mode 100644 index 0000000..f6fa3b1 --- /dev/null +++ b/__tests__/actions/sprint-draft.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue({ + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }), +})) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: vi.fn() }, + user: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }, + $executeRaw: vi.fn().mockResolvedValue(1), + }, +})) + +import { prisma } from '@/lib/prisma' +import { + clearPendingSprintDraftAction, + setPendingSprintDraftAction, +} from '@/actions/sprint-draft' +import type { PendingSprintDraft, UserSettings } from '@/lib/user-settings' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType } + user: { + findUnique: ReturnType + update: ReturnType + } +} + +const validDraft: PendingSprintDraft = { + goal: 'Sprint 1', + pbiIntent: { pbiA: 'all' }, + storyOverrides: { pbiA: { add: [], remove: ['story-1'] } }, +} + +describe('setPendingSprintDraftAction', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findFirst.mockReset() + mockPrisma.user.findUnique.mockReset() + mockPrisma.user.update.mockReset().mockResolvedValue({}) + }) + + it('persists draft for accessible product', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} }) + + const result = await setPendingSprintDraftAction('p1', validDraft) + + expect(result).toEqual({ success: true }) + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + expect(updateArg.data.settings.workflow?.pendingSprintDraft?.p1).toMatchObject({ + goal: 'Sprint 1', + pbiIntent: { pbiA: 'all' }, + }) + }) + + it('preserves drafts for other products', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ + settings: { + workflow: { + pendingSprintDraft: { + p2: { goal: 'P2 draft', pbiIntent: {}, storyOverrides: {} }, + }, + }, + }, + }) + + await setPendingSprintDraftAction('p1', validDraft) + + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + const drafts = updateArg.data.settings.workflow?.pendingSprintDraft + expect(Object.keys(drafts ?? {})).toEqual(expect.arrayContaining(['p1', 'p2'])) + }) + + it('rejects invalid draft (empty goal)', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + + const result = await setPendingSprintDraftAction('p1', { + ...validDraft, + goal: '', + } as PendingSprintDraft) + + expect(result).toHaveProperty('error') + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) + + it('rejects when product not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce(null) + + const result = await setPendingSprintDraftAction('p1', validDraft) + + expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) +}) + +describe('clearPendingSprintDraftAction', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findFirst.mockReset() + mockPrisma.user.findUnique.mockReset() + mockPrisma.user.update.mockReset().mockResolvedValue({}) + }) + + it('removes draft key for product', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ + settings: { + workflow: { + pendingSprintDraft: { + p1: { goal: 'gone', pbiIntent: {}, storyOverrides: {} }, + p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} }, + }, + }, + }, + }) + + await clearPendingSprintDraftAction('p1') + + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + expect(updateArg.data.settings.workflow?.pendingSprintDraft).toEqual({ + p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} }, + }) + }) + + it('is a no-op when there is no draft for the product', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} }) + + const result = await clearPendingSprintDraftAction('p1') + + expect(result).toEqual({ success: true }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) + + it('rejects when product not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce(null) + + const result = await clearPendingSprintDraftAction('p1') + + expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) + }) +}) diff --git a/__tests__/lib/user-settings.test.ts b/__tests__/lib/user-settings.test.ts index 985cf11..1bff8ea 100644 --- a/__tests__/lib/user-settings.test.ts +++ b/__tests__/lib/user-settings.test.ts @@ -135,4 +135,52 @@ describe('UserSettingsSchema', () => { }) } }) + + it('accepts pendingSprintDraft with per-PBI intent and overrides', () => { + const result = UserSettingsSchema.safeParse({ + workflow: { + pendingSprintDraft: { + 'product-1': { + goal: 'Sprint goal', + pbiIntent: { pbiA: 'all', pbiB: 'none' }, + storyOverrides: { + pbiA: { add: [], remove: ['story-1'] }, + pbiB: { add: ['story-2'], remove: [] }, + }, + }, + }, + }, + }) + expect(result.success).toBe(true) + }) + + it('fills empty defaults for pbiIntent and storyOverrides in draft', () => { + const result = UserSettingsSchema.safeParse({ + workflow: { pendingSprintDraft: { 'product-1': { goal: 'g' } } }, + }) + expect(result.success).toBe(true) + if (result.success) { + const draft = result.data.workflow?.pendingSprintDraft?.['product-1'] + expect(draft?.pbiIntent).toEqual({}) + expect(draft?.storyOverrides).toEqual({}) + } + }) + + it('rejects pendingSprintDraft with empty goal', () => { + const result = UserSettingsSchema.safeParse({ + workflow: { pendingSprintDraft: { 'p': { goal: '' } } }, + }) + expect(result.success).toBe(false) + }) + + it('rejects unknown intent value', () => { + const result = UserSettingsSchema.safeParse({ + workflow: { + pendingSprintDraft: { + p: { goal: 'x', pbiIntent: { a: 'partial' } }, + }, + }, + }) + expect(result.success).toBe(false) + }) }) diff --git a/__tests__/stores/user-settings.test.ts b/__tests__/stores/user-settings.test.ts index 019d618..e504ac5 100644 --- a/__tests__/stores/user-settings.test.ts +++ b/__tests__/stores/user-settings.test.ts @@ -1,12 +1,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const updateAction = vi.fn() +const setDraftAction = vi.fn() +const clearDraftAction = vi.fn() vi.mock('@/actions/user-settings', () => ({ updateUserSettingsAction: (...args: unknown[]) => updateAction(...args), })) +vi.mock('@/actions/sprint-draft', () => ({ + setPendingSprintDraftAction: (...args: unknown[]) => setDraftAction(...args), + clearPendingSprintDraftAction: (...args: unknown[]) => + clearDraftAction(...args), +})) + import { useUserSettingsStore } from '@/stores/user-settings/store' +import type { PendingSprintDraft } from '@/lib/user-settings' function resetStore() { useUserSettingsStore.setState((s) => { @@ -20,6 +29,8 @@ function resetStore() { beforeEach(() => { resetStore() updateAction.mockReset() + setDraftAction.mockReset() + clearDraftAction.mockReset() }) afterEach(() => { @@ -85,6 +96,164 @@ describe('useUserSettingsStore', () => { expect(updateAction).not.toHaveBeenCalled() }) + it('setPendingSprintDraft persists draft optimistically + calls action', async () => { + useUserSettingsStore.getState().hydrate({}, false) + setDraftAction.mockResolvedValueOnce({ success: true }) + + const draft: PendingSprintDraft = { + goal: 'Sprint 1', + pbiIntent: { pbiA: 'all' }, + storyOverrides: {}, + } + await useUserSettingsStore + .getState() + .setPendingSprintDraft('product-1', draft) + + const s = useUserSettingsStore.getState() + expect( + s.entities.settings.workflow?.pendingSprintDraft?.['product-1'], + ).toMatchObject({ goal: 'Sprint 1' }) + expect(setDraftAction).toHaveBeenCalledWith('product-1', draft) + }) + + it('setPendingSprintDraft rolls back on server error', async () => { + useUserSettingsStore.getState().hydrate({}, false) + setDraftAction.mockResolvedValueOnce({ error: 'boom' }) + + const draft: PendingSprintDraft = { + goal: 'Sprint X', + pbiIntent: {}, + storyOverrides: {}, + } + await useUserSettingsStore + .getState() + .setPendingSprintDraft('product-1', draft) + + const s = useUserSettingsStore.getState() + expect(s.entities.settings.workflow?.pendingSprintDraft).toBeUndefined() + }) + + it('clearPendingSprintDraft removes key optimistically', async () => { + useUserSettingsStore.getState().hydrate( + { + workflow: { + pendingSprintDraft: { + 'product-1': { + goal: 'Old', + pbiIntent: {}, + storyOverrides: {}, + }, + }, + }, + }, + false, + ) + clearDraftAction.mockResolvedValueOnce({ success: true }) + + await useUserSettingsStore + .getState() + .clearPendingSprintDraft('product-1') + + const s = useUserSettingsStore.getState() + expect( + s.entities.settings.workflow?.pendingSprintDraft?.['product-1'], + ).toBeUndefined() + expect(clearDraftAction).toHaveBeenCalledWith('product-1') + }) + + it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => { + useUserSettingsStore.getState().hydrate( + { + workflow: { + pendingSprintDraft: { + 'product-1': { + goal: 'g', + pbiIntent: { pbiA: 'none' }, + storyOverrides: { + pbiA: { add: ['s-1'], remove: [] }, + pbiB: { add: [], remove: ['s-2'] }, + }, + }, + }, + }, + }, + false, + ) + setDraftAction.mockResolvedValue({ success: true }) + + await useUserSettingsStore + .getState() + .upsertPbiIntent('product-1', 'pbiA', 'all') + + const draft = + useUserSettingsStore.getState().entities.settings.workflow + ?.pendingSprintDraft?.['product-1'] + expect(draft?.pbiIntent.pbiA).toBe('all') + expect(draft?.storyOverrides.pbiA).toBeUndefined() + expect(draft?.storyOverrides.pbiB).toEqual({ add: [], remove: ['s-2'] }) + }) + + it('upsertStoryOverride add adds to add[] and removes from remove[]', async () => { + useUserSettingsStore.getState().hydrate( + { + workflow: { + pendingSprintDraft: { + 'product-1': { + goal: 'g', + pbiIntent: {}, + storyOverrides: { + pbiA: { add: [], remove: ['story-1'] }, + }, + }, + }, + }, + }, + false, + ) + setDraftAction.mockResolvedValue({ success: true }) + + await useUserSettingsStore + .getState() + .upsertStoryOverride('product-1', 'pbiA', 'story-1', 'add') + + const draft = + useUserSettingsStore.getState().entities.settings.workflow + ?.pendingSprintDraft?.['product-1'] + expect(draft?.storyOverrides.pbiA).toEqual({ + add: ['story-1'], + remove: [], + }) + }) + + it('upsertStoryOverride clear removes from both arrays and drops empty entry', async () => { + useUserSettingsStore.getState().hydrate( + { + workflow: { + pendingSprintDraft: { + 'product-1': { + goal: 'g', + pbiIntent: {}, + storyOverrides: { + pbiA: { add: ['story-1'], remove: [] }, + }, + }, + }, + }, + }, + false, + ) + setDraftAction.mockResolvedValue({ success: true }) + + await useUserSettingsStore + .getState() + .upsertStoryOverride('product-1', 'pbiA', 'story-1', 'clear') + + const draft = + useUserSettingsStore.getState().entities.settings.workflow + ?.pendingSprintDraft?.['product-1'] + expect(draft?.storyOverrides.pbiA).toBeUndefined() + }) + it('applyServerPatch merges without optimistic state', () => { useUserSettingsStore.getState().hydrate( { views: { sprintBacklog: { sort: 'code' } } }, diff --git a/actions/sprint-draft.ts b/actions/sprint-draft.ts new file mode 100644 index 0000000..ddbe395 --- /dev/null +++ b/actions/sprint-draft.ts @@ -0,0 +1,121 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' +import type { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' +import { productAccessFilter } from '@/lib/product-access' +import { + mergeSettings, + parseUserSettings, + type PendingSprintDraft, + type UserSettings, +} from '@/lib/user-settings' + +async function getSession() { + return getIronSession(await cookies(), sessionOptions) +} + +const StoryOverridesSchema = z.object({ + add: z.array(z.string()), + remove: z.array(z.string()), +}).strict() + +const DraftSchema = z.object({ + goal: z.string().min(1), + startAt: z.string().datetime().optional(), + endAt: z.string().datetime().optional(), + pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}), + storyOverrides: z.record(z.string(), StoryOverridesSchema).default({}), +}).strict() + +const SetSchema = z.object({ + productId: z.string().min(1), + draft: DraftSchema, +}) + +const ClearSchema = z.object({ + productId: z.string().min(1), +}) + +async function ensureProductAccess(userId: string, productId: string) { + return prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(userId) }, + select: { id: true }, + }) +} + +async function readUserSettings(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { settings: true }, + }) + return parseUserSettings(user?.settings) +} + +async function writeUserSettings(userId: string, next: UserSettings) { + await prisma.user.update({ + where: { id: userId }, + data: { settings: next as unknown as Prisma.InputJsonValue }, + }) +} + +export async function setPendingSprintDraftAction( + productId: string, + draft: PendingSprintDraft, +) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = SetSchema.safeParse({ productId, draft }) + if (!parsed.success) { + return { error: 'Ongeldige draft', issues: parsed.error.issues } + } + + const product = await ensureProductAccess(session.userId, parsed.data.productId) + if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } + + const current = await readUserSettings(session.userId) + const patch: Partial = { + workflow: { + pendingSprintDraft: { + ...(current.workflow?.pendingSprintDraft ?? {}), + [parsed.data.productId]: parsed.data.draft, + }, + }, + } + await writeUserSettings(session.userId, mergeSettings(current, patch)) + revalidatePath('/', 'layout') + return { success: true } +} + +export async function clearPendingSprintDraftAction(productId: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = ClearSchema.safeParse({ productId }) + if (!parsed.success) return { error: 'Ongeldig product-id' } + + const product = await ensureProductAccess(session.userId, parsed.data.productId) + if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } + + const current = await readUserSettings(session.userId) + const existingMap = current.workflow?.pendingSprintDraft + if (!existingMap || !(parsed.data.productId in existingMap)) { + return { success: true } + } + const nextMap = { ...existingMap } + delete nextMap[parsed.data.productId] + const next: UserSettings = { + ...current, + workflow: { ...current.workflow, pendingSprintDraft: nextMap }, + } + await writeUserSettings(session.userId, next) + revalidatePath('/', 'layout') + return { success: true } +} diff --git a/lib/user-settings.ts b/lib/user-settings.ts index ecc7c71..3abcb0a 100644 --- a/lib/user-settings.ts +++ b/lib/user-settings.ts @@ -48,13 +48,36 @@ const LayoutPrefs = z.object({ activeSprints: z.record(z.string(), z.string().nullable()).optional(), }).strict() +const PbiIntent = z.enum(['all', 'none']) + +const StoryOverrides = z.object({ + add: z.array(z.string()), + remove: z.array(z.string()), +}).strict() + +const PendingSprintDraftSchema = z.object({ + goal: z.string().min(1), + startAt: z.string().datetime().optional(), + endAt: z.string().datetime().optional(), + pbiIntent: z.record(z.string(), PbiIntent).default({}), + storyOverrides: z.record(z.string(), StoryOverrides).default({}), +}).strict() + +const WorkflowPrefs = z.object({ + pendingSprintDraft: z.record(z.string(), PendingSprintDraftSchema).optional(), +}).strict() + export const UserSettingsSchema = z.object({ views: ViewsPrefs.optional(), devTools: DevToolsPrefs.optional(), layout: LayoutPrefs.optional(), + workflow: WorkflowPrefs.optional(), }).strict() export type UserSettings = z.infer +export type PendingSprintDraft = z.infer +export type PbiIntent = z.infer +export type StoryOverrides = z.infer export const DEFAULT_USER_SETTINGS: UserSettings = {} diff --git a/stores/user-settings/store.ts b/stores/user-settings/store.ts index abdb038..e6c2b1e 100644 --- a/stores/user-settings/store.ts +++ b/stores/user-settings/store.ts @@ -4,6 +4,8 @@ import { immer } from 'zustand/middleware/immer' import { DEFAULT_USER_SETTINGS, mergeSettings, + type PbiIntent, + type PendingSprintDraft, type UserSettings, } from '@/lib/user-settings' import { updateUserSettingsAction } from '@/actions/user-settings' @@ -28,6 +30,22 @@ interface UserSettingsActions { hydrate: (initial: UserSettings, isDemo: boolean) => void setPref: (path: SettingsPath, value: unknown) => Promise applyServerPatch: (patch: Partial) => void + setPendingSprintDraft: ( + productId: string, + draft: PendingSprintDraft, + ) => Promise + clearPendingSprintDraft: (productId: string) => Promise + upsertPbiIntent: ( + productId: string, + pbiId: string, + intent: PbiIntent, + ) => Promise + upsertStoryOverride: ( + productId: string, + pbiId: string, + storyId: string, + kind: 'add' | 'remove' | 'clear', + ) => Promise } let nextMutationId = 1 @@ -73,6 +91,96 @@ export const useUserSettingsStore = create { + const prev = get().entities.settings as UserSettings + set((s) => { + if (!s.entities.settings.workflow) s.entities.settings.workflow = {} + if (!s.entities.settings.workflow.pendingSprintDraft) { + s.entities.settings.workflow.pendingSprintDraft = {} + } + s.entities.settings.workflow.pendingSprintDraft[productId] = draft + }) + if (get().context.isDemo) return + const { setPendingSprintDraftAction } = await import( + '@/actions/sprint-draft' + ) + const result = await setPendingSprintDraftAction(productId, draft) + if ('error' in result) { + set((s) => { + s.entities.settings = prev as UserSettings + }) + } + }, + + clearPendingSprintDraft: async (productId) => { + const prev = get().entities.settings as UserSettings + set((s) => { + const map = s.entities.settings.workflow?.pendingSprintDraft + if (map) delete map[productId] + }) + if (get().context.isDemo) return + const { clearPendingSprintDraftAction } = await import( + '@/actions/sprint-draft' + ) + const result = await clearPendingSprintDraftAction(productId) + if ('error' in result) { + set((s) => { + s.entities.settings = prev as UserSettings + }) + } + }, + + upsertPbiIntent: async (productId, pbiId, intent) => { + const current = + get().entities.settings.workflow?.pendingSprintDraft?.[productId] + if (!current) return + const nextOverrides = { ...current.storyOverrides } + delete nextOverrides[pbiId] + const next: PendingSprintDraft = { + ...current, + pbiIntent: { ...current.pbiIntent, [pbiId]: intent }, + storyOverrides: nextOverrides, + } + await get().setPendingSprintDraft(productId, next) + }, + + upsertStoryOverride: async (productId, pbiId, storyId, kind) => { + const current = + get().entities.settings.workflow?.pendingSprintDraft?.[productId] + if (!current) return + const existing = current.storyOverrides[pbiId] ?? { add: [], remove: [] } + const dropFrom = (arr: string[]) => arr.filter((id) => id !== storyId) + let nextEntry: { add: string[]; remove: string[] } + switch (kind) { + case 'add': + nextEntry = { + add: existing.add.includes(storyId) ? existing.add : [...existing.add, storyId], + remove: dropFrom(existing.remove), + } + break + case 'remove': + nextEntry = { + add: dropFrom(existing.add), + remove: existing.remove.includes(storyId) + ? existing.remove + : [...existing.remove, storyId], + } + break + case 'clear': + default: + nextEntry = { add: dropFrom(existing.add), remove: dropFrom(existing.remove) } + break + } + const nextOverrides = { ...current.storyOverrides } + if (nextEntry.add.length === 0 && nextEntry.remove.length === 0) { + delete nextOverrides[pbiId] + } else { + nextOverrides[pbiId] = nextEntry + } + const next: PendingSprintDraft = { ...current, storyOverrides: nextOverrides } + await get().setPendingSprintDraft(productId, next) + }, + setPref: async (path, value) => { const patch = patchFromPath(path, value)