Skip to content

frappe/client — data-fetching v3#610

Open
netchampfaris wants to merge 22 commits intomainfrom
data-fetching-v3
Open

frappe/client — data-fetching v3#610
netchampfaris wants to merge 22 commits intomainfrom
data-fetching-v3

Conversation

@netchampfaris
Copy link
Copy Markdown
Contributor

@netchampfaris netchampfaris commented Mar 29, 2026

frappe/client — data-fetching v3

A typed, reactive data-fetching client for Frappe Framework backends. Built on Vue 3 Composition API with a shared DocStore at the core.


Setup

// src/client.ts
import { createClient, createDefaultCacheAdapter } from 'frappe-ui/frappe/vue'

export const { defineDoctype, store } = createClient({
  realtime: true,
  cache: createDefaultCacheAdapter('my-app-cache'),
  onError(error) {
    toast.error(error.messages[0]?.message || error.title)
  },
})

createClient options

Option Type Description
baseUrl? string Prepended to all API requests. Defaults to ''.
realtime? boolean Enable live list_update / doc_rename events via Socket.IO.
cache? CacheAdapter Persistence adapter — see Persistent cache.
onRequest? fn Interceptor called before every request.
onResponse? fn Interceptor called after every successful response.
onError? (error: FrappeResponseError) => void Global error handler.

Defining a DocType

doctypes.ts is auto-generated from your Frappe DocType metadata via bench execute. The example below shows what the generated output looks like — you don't write this by hand.

// src/doctypes.ts  — auto-generated, do not edit
import { defineDoctype } from '@/client'

export const Tasks = defineDoctype<{
  name: string
  title: string
  status: string
  assigned_to: string
}>()({
  doctype: 'GP Task',
  docMethods: {
    markDone: { method: 'mark_done' },
  },
  controllerMethods: {
    getOverdue: { method: 'get_overdue', httpMethod: 'GET' },
  },
})

getDoc — single document

const task = Tasks.getDoc('TASK-001')

task.doc        // GP Task | null
task.loading    // boolean
task.error      // FrappeResponseError | null
task.reload()   // re-fetch

// Update fields
task.setValue.call({ title: 'New title' })
task.setValue.loading

// Delete
task.delete.call()

// Call a doc method
task.markDone.call()

name can be a ref, computed, or getter — changing it automatically fetches the new document.

const taskName = ref('TASK-001')
const task = Tasks.getDoc(taskName)        // refetches when taskName changes
const task = Tasks.getDoc(() => route.params.name)

Options

Option Type Description
enabled? MaybeRefOrGetter<boolean> Pause fetching when false.
transform? (doc: T) => T Applied to every doc read from the store.
onSuccess? (doc: T) => void Called after each successful fetch.
onError? (err) => void Suppresses the global onError handler.

getList — document list

const tasks = Tasks.getList({
  fields: ['name', 'title', 'status', 'assigned_to'],
  filters: { status: 'Open', assigned_to: () => currentUser.value },
  orderBy: 'modified desc',
  limit: 20,
})

tasks.data          // GP Task[]
tasks.loading
tasks.hasNextPage
tasks.next()        // fetch next page
tasks.previous()
tasks.reload()

// Insert
tasks.insert.call({ title: 'New task' })
// Insert at end
tasks.insert.call({ title: 'New task' }, { at: 'end' })

// Update a row by name
tasks.setValue.call({ name: 'TASK-001', status: 'Closed' })

// Delete a row
tasks.delete.call('TASK-001')

Filters are fully reactive — any ref, computed, or getter value is watched and triggers a debounced refetch.

const status = ref('Open')
const tasks = Tasks.getList({
  filters: { status },                         // ref — watched automatically
  filters: { status: () => activeFilter.value } // getter — same behavior
})

Options

Option Type Description
fields? string[] Fields to fetch.
filters? ReactiveFilters | MaybeRefOrGetter<object> Per-key or whole-object reactive filters. undefined values are omitted.
orderBy? string SQL order clause, e.g. 'modified desc'.
limit? number Page size. Defaults to 20.
start? number Initial offset. Defaults to 0.
debounce? number Debounce filter changes in ms.
enabled? MaybeRefOrGetter<boolean> Pause fetching when false.
transform? (doc: T) => T Applied to each doc.
onSuccess? (docs: T[]) => void Called after each successful fetch.
onError? (err) => void Suppresses the global onError handler.

newDoc — draft document

Local-only reactive document. Nothing is sent to the server until insert.call().

const draft = Tasks.newDoc({ status: 'Open' })

draft.doc.title = 'My task'   // bind to form inputs with v-model
draft.insert.call()         // POST — merges doc + any extra values

getCount — reactive count

const count = Tasks.getCount({
  filters: { status: 'Open' },
})

count.data     // number | null
count.loading

Controller methods

Methods defined in controllerMethods are exposed directly on the instance:

const result = await Tasks.getOverdue.call()
Tasks.getOverdue.loading
Tasks.getOverdue.data

Realtime

Subscribe to live events for a doctype:

Tasks.onUpdate((name) => {
  console.log('doc updated:', name)
})

Tasks.onRename((newName, oldName) => {
  console.log('renamed:', oldName, '->', newName)
})

Both return an unsubscribe function.


Bulk operations

// Delete multiple docs
Tasks.bulkDelete.call(['TASK-001', 'TASK-002'])

// Update multiple docs
Tasks.bulkUpdate.call([
  { name: 'TASK-001', status: 'Closed' },
  { name: 'TASK-002', status: 'Closed' },
])

Optimistic updates

All mutations expose .callOptimistic() alongside .call(). The store is updated immediately and rolled back automatically if the request fails.

getDoc mutations

const task = Tasks.getDoc('TASK-001')

// Applies status change to the store instantly; reverts on error
await task.setValue.callOptimistic({ status: 'Closed' })

// Custom updater — apply any store change optimistically
await task.setValue.callOptimistic(
  { status: 'Closed' },
  (store) => store.set({ ...store.get('GP Task', task.doc.name), status: 'Closed', closedAt: Date.now() }),
)

// Remove from store immediately
await task.delete.callOptimistic()

getList mutations

const tasks = Tasks.getList({ fields: ['name', 'title', 'status'] })

// Update a row optimistically
await tasks.setValue.callOptimistic({ name: 'TASK-001', status: 'Closed' })

// Remove from list + store immediately
await tasks.delete.callOptimistic('TASK-001')

// Insert with a visible placeholder during the round-trip
await tasks.insert.callOptimistic(
  { title: 'New task', status: 'Open' },          // values sent to server
  { name: 'temp', title: 'New task', status: 'Open' }, // tempDoc shown in list
  { at: 'start' },                                // position
)

File upload

import { uploadFile } from 'frappe-ui/frappe/vue'

const upload = uploadFile({
  file: event.target.files[0],
  doctype: 'GP Task',
  name: 'TASK-001',
  fieldname: 'attachment',
})

upload.call()
upload.progress   // 0–100
upload.loading
upload.data       // Frappe File doc on success
upload.abort()    // cancel

Persistent cache

Pass a cache adapter to createClient() to persist the DocStore to IndexedDB. On the next page load, docs are hydrated instantly before any network requests fire — no loading flash.

import { createClient, createDefaultCacheAdapter } from 'frappe-ui/frappe/vue'

createClient({
  cache: createDefaultCacheAdapter('my-app-cache'),
})

createDefaultCacheAdapter uses IndexedDB in browsers and falls back to in-memory in non-browser environments. Each app should pass a unique dbName to avoid collisions.

- useFrappeFetch: reactive HTTP client with smart params handling
- defineDoctype: type-safe DocType definitions with getDoc method
- Comprehensive test suite (19 tests) with MSW mocking
- PRD documenting complete API design
- Add getList method with pagination, filtering, sorting
- Support reactive options (MaybeRefOrGetter) with auto-refetch
- Add debouncing and request cancellation (AbortController)
- Simplify defineDoctype API (remove currying)
- Rename result.data to result.json
- Add 13 tests for defineDoctype (28 total passing)
- Implement setValue/delete/insert with optimistic updates API
- Refactor useFrappeFetch with reactive options and callbacks
- Add comprehensive test coverage
- update PRD
- log unhandled exception on automatic fetches
- for holding custom options like baseUrl, realtime, onError, etc
- returns defineDoctype with custom options applied
- DoctypeDefinition type combines doctype string with DoctypeOptions
- defineDoctype() now takes a single definition object instead of
  (doctype, options) — cleaner API aligned with the new spec
- Remove frappe/index.d.ts (moved to index.d.ts.bak)
- Update all tests and docs to match the new signature
Implements the core store layer as specified in frappe/client/spec/:

- DocStore: in-memory doc store with merge semantics, synchronous
  subscriptions (per-doc and per-doctype), and cold-start hydration
- RequestManager: HTTP layer with GET deduplication, CSRF header,
  and onRequest/onError/onResponse hooks
- FrappeResponseError: structured error class with isNotFound,
  isPermission, isAuth, isValidation convenience getters
- CacheAdapter: write-behind persistence interface + memory impl
- SocketManager: bridges Socket.IO doc_update/doc_rename to DocStore
- Operation<TParams, TResult>: shared call/callOptimistic contract
- CoreDocHandle: single-doc handle with setValue, delete, doc methods,
  and automatic snapshot revert on optimistic error
- CoreListHandle: list handle with DocStore-backed data, pagination,
  insert/setValue/delete with callOptimistic support
- createCoreClient: factory wiring everything together

All components have zero Vue dependencies. 62 tests pass.
Old WIP files (defineDoctype.ts, getDoc.ts, etc.) are untouched.
- ReactiveOperation: wraps core Operation with loading/error/data refs
- vueDocHandle: Vue-reactive single-doc handle (shallowRef, watchEffect,
  nextTick batching, tryOnScopeDispose, reactive() unwrapping for templates)
- vueListHandle: Vue-reactive list handle with per-key and whole-object
  filter support, auto-optimistic defaults for setValue/delete, InsertOperation
  with tempDoc placeholder and InsertPosition (start/end/index)
- createVueClient: defineDoctype() factory wiring store + requestManager
- Operation.callOptimistic updater made optional (auto-defaults in handles)
- package.json: add ./frappe/vue export
- 42 tests across 3 test files, 190 total passing
…scription

- Add onDocUpdate(doctype, cb) and onDocRename(doctype, cb) to SocketManager interface
- Implement in createSocketManager, createLazySocketManager, createNoopSocketManager
- Use list_update event (correct Frappe event name) instead of doc_update
- Payload shape: { doctype, name, user } — dispatch as list_update:${doctype}
- Reference-counted doctype_subscribe/doctype_unsubscribe for server room membership
- Fix doc_rename payload keys: old/new instead of old_name/doc
- Add fetchDoc(doctype, name) to RequestManager interface and implementation
- fetchDoc encodes the name and delegates to fetch()
- Add _suppressGlobalError flag to FrappeResponseError
- Defer global onError via setTimeout(0) so local handlers can suppress it
- Update RequestManager test to use fake timers for the deferred error assertion
- vueListHandle auto-subscribes via socket.onDocUpdate when socket is provided
- On list_update: if name is in current page, fetchDoc individually (no flicker)
- If name is unknown, reload the page (may be a new insert)
- Add ReactiveOperationOptions.onError to suppress global error handler per-op
- Use requestManager.fetchDoc() at the call site
…Rename

- frappe-socket.ts: initFrappeSocket() connects to Frappe's Socket.IO using
  site_name and __FRAPPE_SOCKETIO_PORT__ injected by the Vite plugin
- createVueClient: switch to createLazySocketManager with initFrappeSocket
- Add newDoc(), getCount(), bulkDelete, bulkUpdate to VueDoctypeInstance
- Add onUpdate(cb)/onRename(cb) convenience methods on the doctype instance
- New files: vueNewDocHandle.ts, vueCountHandle.ts, uploadFile.ts
- Export new handles and types from vue/index.ts
…tests

- frappeProxy: inject __FRAPPE_SOCKETIO_PORT__ from common_site_config.json
- frappe/index.js: export initFrappeSocket
- core/__tests__/mocks.ts: add bulk_delete, bulk_update, and count MSW handlers
- vue/__tests__/bulkAndRealtime.test.ts: tests for bulkDelete, bulkUpdate, realtime list_update and doc_rename
- vue/__tests__/vueNewDocAndCount.test.ts: tests for newDoc and getCount
- Add createIDBCacheAdapter() using idb-keyval with per-app custom stores
- Add createDefaultCacheAdapter() that picks IDB in browsers, falls back to memory
- Add DocStore.subscribeAll() for write-behind cache updates
- Wire connectCache() to subscribe before hydration to avoid missed writes
- Expose cache factories from frappe/client/vue entry point
- Accept optional cache option in createClient()
- Rewrite api.md, core.md, vue.md to reflect actual API surface
- Remove wip implementation files and tests from frappe/client/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant