├── .eslintrc ├── .npmrc ├── feathers-pinia-3 ├── use-auth │ ├── index.ts │ └── use-auth.ts ├── isomorphic-mongo-objectid.d.ts ├── localstorage │ ├── index.ts │ ├── clear-storage.ts │ └── storage-sync.ts ├── use-find-get │ ├── index.ts │ ├── types.ts │ ├── utils.ts │ ├── utils-pagination.ts │ ├── use-get.ts │ └── use-find.ts ├── utils │ ├── index.ts │ ├── use-counter.ts │ ├── use-instance-defaults.ts │ ├── convert-data.ts │ ├── define-properties.ts │ ├── deep-unref.ts │ └── utils.ts ├── modeling │ ├── index.ts │ ├── store-associated.ts │ ├── use-model-instance.ts │ ├── use-feathers-instance.ts │ └── types.ts ├── unplugin-auto-import-preset.ts ├── index.ts ├── hooks │ ├── 7-skip-get-if-exists.ts │ ├── 0-prepare-query.ts │ ├── 2-event-locks.ts │ ├── 4-model-instances.ts │ ├── index.ts │ ├── 6-normalize-find.ts │ ├── 3-sync-store.ts │ ├── 1-set-pending.ts │ ├── 5-handle-find-ssr.ts │ └── 8-patch-diffs.ts ├── stores │ ├── index.ts │ ├── temps.ts │ ├── event-locks.ts │ ├── utils-custom-operators.ts │ ├── event-queue-promise.ts │ ├── all-storage-types.ts │ ├── use-data-store.ts │ ├── pending.ts │ ├── storage.ts │ ├── events.ts │ ├── pagination.ts │ ├── use-service-store.ts │ ├── clones.ts │ ├── types.ts │ └── local-queries.ts ├── feathers-ofetch.ts ├── types.ts ├── create-pinia-client.ts └── create-pinia-service.ts ├── assets ├── README.md └── reset.css ├── pages ├── README.md ├── app │ ├── reminders.vue │ ├── me.vue │ ├── contacts │ │ ├── new.vue │ │ ├── [id].vue │ │ └── index.vue │ ├── index.vue │ ├── tweets.vue │ └── contacts.vue ├── index.vue └── login.vue ├── public ├── README.md └── feathersjs.svg ├── server └── README.md ├── plugins ├── README.md ├── auto-animate.ts ├── 2.feathers-auth.ts └── 1.feathers.ts ├── components ├── README.md ├── Task.vue ├── Loading.vue ├── Contacts │ ├── Menu │ │ ├── ContactsMenuItem.vue │ │ ├── ContactsMenu.vue │ │ └── ContactsFilterMenu.vue │ ├── ContextMenu.vue │ └── Form │ │ └── ContactsForm.vue ├── FormControlText.vue ├── FormControlText copy.vue ├── DeleteDialog.vue ├── TweetComposer.vue ├── TaskListItem.vue ├── TaskList.vue ├── Tweet.vue └── AppNav.vue ├── composables ├── README.md ├── feathers.ts └── use-themes.ts ├── middleware ├── README.md └── session.global.ts ├── .gitignore ├── app.config.ts ├── .vscode └── settings.json ├── tsconfig.json ├── layouts ├── default.vue ├── app.vue └── README.md ├── models ├── global-config.ts ├── user.ts └── task.ts ├── stores └── auth.ts ├── tailwind.config.js ├── formkit.config.ts ├── app.vue ├── nuxt.config.ts ├── README.md ├── package.json └── formkit.daisyui.ts /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@antfu" 3 | } -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /feathers-pinia-3/use-auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './use-auth' 2 | -------------------------------------------------------------------------------- /feathers-pinia-3/isomorphic-mongo-objectid.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'isomorphic-mongo-objectid' 2 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | # Assets Directory 2 | 3 | See https://v3.nuxtjs.org/guide/directory-structure/assets -------------------------------------------------------------------------------- /pages/README.md: -------------------------------------------------------------------------------- 1 | # Pages Directory 2 | 3 | See https://v3.nuxtjs.org/guide/directory-structure/pages -------------------------------------------------------------------------------- /public/README.md: -------------------------------------------------------------------------------- 1 | # Public Directory 2 | 3 | See https://v3.nuxtjs.org/guide/directory-structure/public -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Server Directory 2 | 3 | See https://v3.nuxtjs.org/guide/directory-structure/server -------------------------------------------------------------------------------- /feathers-pinia-3/localstorage/index.ts: -------------------------------------------------------------------------------- 1 | export * from './storage-sync' 2 | export * from './clear-storage' 3 | -------------------------------------------------------------------------------- /plugins/README.md: -------------------------------------------------------------------------------- 1 | # Plugins directory 2 | 3 | See 4 | -------------------------------------------------------------------------------- /components/README.md: -------------------------------------------------------------------------------- 1 | # Components Directory 2 | 3 | See https://v3.nuxtjs.org/guide/directory-structure/components -------------------------------------------------------------------------------- /composables/README.md: -------------------------------------------------------------------------------- 1 | # Composables Directory 2 | 3 | See https://v3.nuxtjs.org/guide/directory-structure/composables -------------------------------------------------------------------------------- /middleware/README.md: -------------------------------------------------------------------------------- 1 | # Middleware Directory 2 | 3 | See https://v3.nuxtjs.org/guide/directory-structure/middleware -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /app.config.ts: -------------------------------------------------------------------------------- 1 | // See https://v3.nuxtjs.org/guide/directory-structure/app.config 2 | 3 | export default defineAppConfig({}) 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.enable": false, 3 | "editor.codeActionsOnSave": { 4 | "source.fixAll.eslint": true 5 | } 6 | } -------------------------------------------------------------------------------- /feathers-pinia-3/use-find-get/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types' 2 | 3 | export { useFind } from './use-find' 4 | export { useGet } from './use-get' 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://v3.nuxtjs.org/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json", 4 | "compilerOptions": { 5 | "strict": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /plugins/auto-animate.ts: -------------------------------------------------------------------------------- 1 | import { autoAnimatePlugin } from '@formkit/auto-animate/vue' 2 | 3 | export default defineNuxtPlugin((nuxtApp) => { 4 | nuxtApp.vueApp.use(autoAnimatePlugin) 5 | }) 6 | -------------------------------------------------------------------------------- /components/Task.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /components/Loading.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /composables/feathers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Provides access to Feathers Clients throughout the app 3 | */ 4 | export const useFeathers = () => { 5 | const { $api } = useNuxtApp() 6 | return { api: $api } 7 | } 8 | -------------------------------------------------------------------------------- /plugins/2.feathers-auth.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Make sure reAuthenticate finishes before we begin rendering. 3 | */ 4 | export default defineNuxtPlugin(async (_nuxtApp) => { 5 | const auth = useAuthStore() 6 | await auth.reAuthenticate() 7 | }) 8 | -------------------------------------------------------------------------------- /feathers-pinia-3/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils' 2 | export * from './use-counter' 3 | export * from './convert-data' 4 | export * from './define-properties' 5 | export * from './deep-unref' 6 | export * from './use-instance-defaults' 7 | -------------------------------------------------------------------------------- /feathers-pinia-3/modeling/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types' 2 | 3 | export { useServiceInstance } from './use-feathers-instance' 4 | export { useModelInstance } from './use-model-instance' 5 | export { storeAssociated } from './store-associated' 6 | -------------------------------------------------------------------------------- /layouts/app.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | -------------------------------------------------------------------------------- /models/global-config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a global configuration object for Feathers-Pinia 3 | */ 4 | export const useFeathersPiniaConfig = () => { 5 | const { $pinia: pinia } = useNuxtApp() 6 | return { 7 | pinia, 8 | idField: '_id', 9 | whitelist: ['$regex'], 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pages/app/reminders.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /pages/app/me.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /feathers-pinia-3/unplugin-auto-import-preset.ts: -------------------------------------------------------------------------------- 1 | export const feathersPiniaAutoImport = { 2 | 'feathers-pinia': [ 3 | 'useServiceInstance', 4 | 'useInstanceDefaults', 5 | 'useDataStore', 6 | 'useAuth', 7 | 'createPiniaClient', 8 | 'defineGetters', 9 | 'defineSetters', 10 | 'defineValues', 11 | ], 12 | } 13 | -------------------------------------------------------------------------------- /layouts/README.md: -------------------------------------------------------------------------------- 1 | ## Layouts 2 | 3 | Vue components in this dir are used as layouts. 4 | 5 | By default, `default.vue` will be used unless an alternative is specified in the route meta. 6 | 7 | ```html 8 | 13 | ``` 14 | 15 | Learn more on https://v3.nuxtjs.org/guide/directory-structure/layouts -------------------------------------------------------------------------------- /models/user.ts: -------------------------------------------------------------------------------- 1 | import { type FeathersInstance, useInstanceDefaults } from '~~/feathers-pinia-3' 2 | import type { Users } from 'feathers-pinia-api' 3 | 4 | export const setupUser = (data: FeathersInstance) => { 5 | const defaults = { 6 | email: '', 7 | password: '', 8 | } 9 | const withDefaults = useInstanceDefaults(defaults, data) 10 | 11 | return withDefaults 12 | } 13 | -------------------------------------------------------------------------------- /stores/auth.ts: -------------------------------------------------------------------------------- 1 | import { acceptHMRUpdate, defineStore } from 'pinia' 2 | import { useAuth } from '~~/feathers-pinia-3' 3 | 4 | // stores/auth.ts 5 | 6 | export const useAuthStore = defineStore('auth', () => { 7 | const { api } = useFeathers() 8 | 9 | const auth = useAuth({ api, servicePath: 'users' }) 10 | 11 | return auth 12 | }) 13 | 14 | if (import.meta.hot) 15 | import.meta.hot.accept(acceptHMRUpdate(useAuthStore as any, import.meta.hot)) 16 | -------------------------------------------------------------------------------- /feathers-pinia-3/utils/use-counter.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue-demi' 2 | 3 | /** 4 | * Use a counter to track the number of pending queries. Prevents collisions with overlapping queries. 5 | */ 6 | export const useCounter = () => { 7 | const count = ref(0) 8 | const add = () => { 9 | count.value = count.value + 1 10 | } 11 | const sub = () => { 12 | count.value = count.value === 0 ? 0 : count.value - 1 13 | } 14 | return { count, add, sub } 15 | } 16 | -------------------------------------------------------------------------------- /feathers-pinia-3/utils/use-instance-defaults.ts: -------------------------------------------------------------------------------- 1 | import fastCopy from 'fast-copy' 2 | import { AnyData } from '../types' 3 | import { _ } from '@feathersjs/commons' 4 | 5 | export const useInstanceDefaults = (defaults: D, data: M) => { 6 | const dataKeys = Object.keys(data) 7 | const defaultsToApply = _.omit(defaults, ...dataKeys) as D 8 | const cloned = Object.assign(data, fastCopy(defaultsToApply)) 9 | 10 | return cloned 11 | } 12 | -------------------------------------------------------------------------------- /feathers-pinia-3/localstorage/clear-storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Clears all services from localStorage. You might use this when a user 3 | * logs out to make sure their data doesn't persist for the next user. 4 | * 5 | * @param storage an object using the Storage interface 6 | */ 7 | export function clearStorage(storage: Storage = window.localStorage) { 8 | Object.keys(storage).map((key) => { 9 | if (key.startsWith('service.')) { 10 | storage.removeItem(key) 11 | } 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /middleware/session.global.ts: -------------------------------------------------------------------------------- 1 | // middleware/session.global.ts 2 | export default defineNuxtRouteMiddleware(async (to, _from) => { 3 | const auth = useAuthStore() 4 | 5 | await auth.getPromise() 6 | 7 | // Allow 404 page to show 8 | if (!to.matched.length) 9 | return 10 | 11 | // if user is not logged in, redirect to '/' when not navigating to a public page. 12 | const publicRoutes = ['/', '/login'] 13 | if (!auth.user) { 14 | if (!publicRoutes.includes(to.path)) 15 | return navigateTo('/') 16 | } 17 | }) 18 | -------------------------------------------------------------------------------- /components/Contacts/Menu/ContactsMenuItem.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | -------------------------------------------------------------------------------- /composables/use-themes.ts: -------------------------------------------------------------------------------- 1 | export const useThemes = () => [ 2 | 'system', 3 | 'light', 4 | 'dark', 5 | 'cupcake', 6 | 'bumblebee', 7 | 'emerald', 8 | 'corporate', 9 | 'synthwave', 10 | 'retro', 11 | 'cyberpunk', 12 | 'valentine', 13 | 'halloween', 14 | 'garden', 15 | 'forest', 16 | 'aqua', 17 | 'lofi', 18 | 'pastel', 19 | 'fantasy', 20 | 'wireframe', 21 | 'black', 22 | 'luxury', 23 | 'dracula', 24 | 'cmyk', 25 | 'autumn', 26 | 'business', 27 | 'acid', 28 | 'lemonade', 29 | 'night', 30 | 'coffee', 31 | 'winter', 32 | ] 33 | -------------------------------------------------------------------------------- /feathers-pinia-3/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types' 2 | 3 | export { createPiniaClient } from './create-pinia-client' 4 | export { PiniaService } from './create-pinia-service' 5 | export { OFetch } from './feathers-ofetch' 6 | 7 | export { feathersPiniaAutoImport } from './unplugin-auto-import-preset' 8 | 9 | export * from './hooks/' 10 | export * from './localstorage/' 11 | export * from './modeling/' 12 | export * from './use-auth/' 13 | export * from './use-find-get/' 14 | export * from './stores' 15 | export { useInstanceDefaults, defineGetters, defineSetters, defineValues } from './utils/' 16 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { addDynamicIconSelectors } = require('@iconify/tailwind') 2 | const FormKitVariants = require('@formkit/themes/tailwindcss') 3 | 4 | export default { 5 | darkMode: 'class', 6 | plugins: [ 7 | require('daisyui'), 8 | addDynamicIconSelectors(), 9 | FormKitVariants, 10 | ], 11 | content: [ 12 | './src/**/*.{html,js,vue}', 13 | 'node_modules/daisy-ui-kit/**/*.{vue,js}', 14 | './node_modules/@formkit/themes/dist/tailwindcss/genesis/index.cjs', 15 | './formkit.daisyui.ts', 16 | ], 17 | theme: { 18 | extend: { 19 | colors: { 20 | }, 21 | }, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /models/task.ts: -------------------------------------------------------------------------------- 1 | import type { Tasks } from 'feathers-pinia-api' 2 | import type { FeathersInstance } from '~~/feathers-pinia-3' 3 | 4 | export function setupTask(data: FeathersInstance): Record { 5 | const defaults = { 6 | description: '', 7 | isComplete: false, 8 | } 9 | const withDefaults = useInstanceDefaults(defaults, data) 10 | 11 | // add user to each task 12 | // const User = useUserModel() as any 13 | // const withUser = associateGet(withDefaults, 'user', { 14 | // Model: User, 15 | // getId: (data: FeathersInstance) => data.userId as string, 16 | // }) 17 | return withDefaults 18 | } 19 | -------------------------------------------------------------------------------- /components/Contacts/ContextMenu.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /feathers-pinia-3/hooks/7-skip-get-if-exists.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext, NextFunction } from '@feathersjs/feathers' 2 | 3 | export const skipGetIfExists = () => async (context: HookContext, next: NextFunction) => { 4 | const { params, id } = context 5 | const store = context.service.store 6 | 7 | if (context.method === 'get' && id != null) { 8 | const skipIfExists = params.skipGetIfExists || store.skipGetIfExists 9 | delete params.skipGetIfExists 10 | 11 | // If the records is already in store, return it 12 | const existingItem = store.getFromStore(context.id, params) 13 | if (existingItem && skipIfExists) context.result = existingItem 14 | } 15 | await next() 16 | } 17 | -------------------------------------------------------------------------------- /formkit.config.ts: -------------------------------------------------------------------------------- 1 | import { en, fr } from '@formkit/i18n' 2 | import type { DefaultConfigOptions } from '@formkit/vue' 3 | import { generateClasses } from '@formkit/themes' 4 | import { genesisIcons } from '@formkit/icons' 5 | import { createAutoAnimatePlugin } from '@formkit/addons' 6 | import daisy from './formkit.daisyui' 7 | // import genesis from '@formkit/themes/tailwindcss/genesis' 8 | 9 | const config: DefaultConfigOptions = { 10 | // theme: 'genesis', 11 | locales: { fr, en }, 12 | locale: 'en', 13 | icons: { 14 | ...genesisIcons, 15 | }, 16 | config: { 17 | classes: generateClasses(daisy), 18 | }, 19 | plugins: [ 20 | createAutoAnimatePlugin(), 21 | ], 22 | } 23 | 24 | export default config 25 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 2 | 19 | 20 | 27 | -------------------------------------------------------------------------------- /feathers-pinia-3/hooks/0-prepare-query.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext, NextFunction } from '@feathersjs/feathers' 2 | import { deepUnref } from '../utils' 3 | 4 | /** 5 | * deeply unrefs `params.query` 6 | */ 7 | export const unrefQuery = () => async (context: HookContext, next: NextFunction) => { 8 | if (context.params.query) { 9 | context.params.query = deepUnref(context.params.query) 10 | } 11 | 12 | if (context.method === 'find') { 13 | const query = context.params.query || {} 14 | if (query.$limit == null) { 15 | query.$limit = context.service.store.defaultLimit 16 | } 17 | if (query.$skip == null) { 18 | query.$skip = 0 19 | } 20 | context.params.query = query 21 | } 22 | 23 | await next() 24 | } 25 | -------------------------------------------------------------------------------- /feathers-pinia-3/modeling/store-associated.ts: -------------------------------------------------------------------------------- 1 | import { defineValues } from '../utils' 2 | 3 | export function storeAssociated(this: any, data: any, config: Record) { 4 | const updatedValues: any = {} 5 | Object.keys(config).forEach((key) => { 6 | const related = data[key] 7 | const servicePath = config[key] 8 | const service = this.service(servicePath) 9 | if (!service) 10 | console.error('there is no service at path ' + servicePath + 'check your storeAssociated config', data, config) 11 | if (related && service) { 12 | const created = service.createInStore(related) 13 | updatedValues[key] = created 14 | } 15 | }) 16 | 17 | defineValues(data, updatedValues) 18 | 19 | return 20 | } 21 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://v3.nuxtjs.org/api/configuration/nuxt.config 2 | export default defineNuxtConfig({ 3 | modules: [ 4 | // '@nuxt/devtools', 5 | '@pinia/nuxt', 6 | '@nuxtjs/tailwindcss', 7 | '@vueuse/nuxt', 8 | '@nuxtjs/color-mode', 9 | 'daisy-ui-kit/nuxt', 10 | 'nuxt-feathers-pinia', 11 | '@formkit/nuxt', 12 | ], 13 | imports: { 14 | dirs: [ 15 | 'stores', 16 | 'models', 17 | ], 18 | }, 19 | colorMode: { 20 | preference: 'system', 21 | // fallback: 'light', 22 | // classPrefix: '', 23 | dataValue: 'theme', 24 | classSuffix: '', 25 | }, 26 | typescript: { 27 | shim: false, 28 | }, 29 | experimental: { 30 | reactivityTransform: true, 31 | }, 32 | }) 33 | -------------------------------------------------------------------------------- /feathers-pinia-3/hooks/2-event-locks.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext, NextFunction } from '@feathersjs/feathers' 2 | 3 | export const eventLocks = () => async (context: HookContext, next: NextFunction) => { 4 | const { id, method } = context 5 | const store = context.service.store 6 | const isLockableMethod = ['update', 'patch', 'remove'].includes(method) 7 | const eventNames: any = { 8 | update: 'updated', 9 | patch: 'patched', 10 | remove: 'removed', 11 | } 12 | const eventName = eventNames[method] 13 | 14 | if (isLockableMethod && id && !store.isSsr) 15 | store.toggleEventLock(id, eventName) 16 | 17 | await next() 18 | 19 | if (isLockableMethod && id && !store.isSsr) 20 | store.clearEventLock(id, eventName) 21 | } 22 | -------------------------------------------------------------------------------- /feathers-pinia-3/hooks/4-model-instances.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext, NextFunction } from '@feathersjs/feathers' 2 | import type { AnyData } from '../types' 3 | 4 | export const makeModelInstances = () => { 5 | return async (context: HookContext, next: NextFunction) => { 6 | if (next) 7 | await next() 8 | 9 | if (context.service.new) { 10 | if (Array.isArray(context.result?.data)) 11 | context.result.data = context.result.data.map((i: AnyData) => context.service.new(i)) 12 | 13 | else if (Array.isArray(context.result)) 14 | context.result = context.result.map((i: AnyData) => context.service.new(i)) 15 | 16 | else 17 | context.result = context.service.new(context.result) 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/index.ts: -------------------------------------------------------------------------------- 1 | export { useDataStore, type UseDataStoreOptions } from './use-data-store' 2 | export { useServiceStore, type UseServiceStoreOptions } from './use-service-store' 3 | 4 | export { useQueuePromise } from './event-queue-promise' 5 | export { useServiceLocal } from './local-queries' 6 | export { useServiceTemps } from './temps' 7 | export { useServiceEvents } from './events' 8 | export { useServiceClones } from './clones' 9 | export { useServicePending } from './pending' 10 | export { useServiceStorage } from './storage' 11 | export { useAllStorageTypes } from './all-storage-types' 12 | export { useServiceEventLocks } from './event-locks' 13 | export { useServicePagination } from './pagination' 14 | 15 | export * from './types' 16 | -------------------------------------------------------------------------------- /components/Contacts/Menu/ContactsMenu.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 26 | -------------------------------------------------------------------------------- /pages/app/contacts/new.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 31 | -------------------------------------------------------------------------------- /components/FormControlText.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 25 | -------------------------------------------------------------------------------- /feathers-pinia-3/utils/convert-data.ts: -------------------------------------------------------------------------------- 1 | import type { FeathersService } from '@feathersjs/feathers' 2 | import type { PiniaService } from '../create-pinia-service' 3 | import type { AnyData, AnyDataOrArray } from '../types' 4 | import { ServiceInstance } from '../modeling' 5 | 6 | export function convertData(service: PiniaService, result: AnyDataOrArray) { 7 | if (!result) { 8 | return result 9 | } else if (Array.isArray(result)) { 10 | return result.map((i) => service.new(i)) as ServiceInstance[] 11 | } else if (result && Array.isArray(result.data)) { 12 | result.data = result.data.map((i) => service.new(i)) as ServiceInstance[] 13 | return result 14 | } else { 15 | return service.new(result) as ServiceInstance 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pages/app/index.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 33 | -------------------------------------------------------------------------------- /feathers-pinia-3/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import { unrefQuery } from './0-prepare-query' 2 | import { setPending } from './1-set-pending' 3 | import { eventLocks } from './2-event-locks' 4 | import { syncStore } from './3-sync-store' 5 | import { makeModelInstances } from './4-model-instances' 6 | import { handleFindSsr } from './5-handle-find-ssr' 7 | import { normalizeFind } from './6-normalize-find' 8 | import { skipGetIfExists } from './7-skip-get-if-exists' 9 | import { patchDiffing } from './8-patch-diffs' 10 | 11 | export { syncStore, setPending, eventLocks, normalizeFind, skipGetIfExists, makeModelInstances } 12 | 13 | export const feathersPiniaHooks = () => [ 14 | unrefQuery(), 15 | setPending(), 16 | eventLocks(), 17 | syncStore(), 18 | makeModelInstances(), 19 | handleFindSsr(), 20 | normalizeFind(), 21 | skipGetIfExists(), 22 | patchDiffing(), 23 | ] 24 | -------------------------------------------------------------------------------- /components/Contacts/Menu/ContactsFilterMenu.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 28 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/temps.ts: -------------------------------------------------------------------------------- 1 | import type { AnyData } from '../types' 2 | import type { beforeWriteFn, onReadFn } from './types' 3 | import type { StorageMapUtils } from './storage' 4 | import { useServiceStorage } from './storage' 5 | 6 | interface UseServiceTempsOptions { 7 | getId: (item: M) => string 8 | itemStorage: StorageMapUtils 9 | onRead?: onReadFn 10 | beforeWrite?: beforeWriteFn 11 | } 12 | 13 | export const useServiceTemps = (options: UseServiceTempsOptions) => { 14 | const { getId, itemStorage, onRead, beforeWrite } = options 15 | 16 | const tempStorage = useServiceStorage({ 17 | getId, 18 | onRead, 19 | beforeWrite, 20 | }) 21 | 22 | function moveTempToItems(data: M) { 23 | if (tempStorage.has(data)) tempStorage.remove(data) 24 | 25 | return itemStorage.set(data) 26 | } 27 | 28 | return { tempStorage, moveTempToItems } 29 | } 30 | -------------------------------------------------------------------------------- /pages/app/contacts/[id].vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 36 | -------------------------------------------------------------------------------- /feathers-pinia-3/feathers-ofetch.ts: -------------------------------------------------------------------------------- 1 | import { FetchClient } from '@feathersjs/rest-client' 2 | import type { Params } from '@feathersjs/feathers' 3 | 4 | // A feathers-rest transport adapter for https://github.com/unjs/ofetch 5 | export class OFetch extends FetchClient { 6 | async request(options: any, params: Params) { 7 | const fetchOptions = Object.assign({}, options, (params as any).connection) 8 | 9 | fetchOptions.headers = Object.assign({ Accept: 'application/json' }, this.options.headers, fetchOptions.headers) 10 | 11 | if (options.body) fetchOptions.body = options.body 12 | 13 | try { 14 | const response = await this.connection.raw(options.url, fetchOptions) 15 | const { _data, status } = response 16 | 17 | if (status === 204) return null 18 | return _data 19 | } catch (error) { 20 | console.error('feathers-ofetch request error', error) 21 | throw error 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /components/FormControlText copy.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 31 | -------------------------------------------------------------------------------- /feathers-pinia-3/hooks/6-normalize-find.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext, NextFunction } from '@feathersjs/feathers' 2 | import { hasOwn } from '../utils' 3 | 4 | /** 5 | * Normalizes two things 6 | * - pagination across all adapters, including @feathersjs/memory 7 | * - the find response so that it always holds data at `response.data` 8 | * @returns { data: AnyData[] } 9 | */ 10 | export const normalizeFind = () => async (context: HookContext, next?: NextFunction) => { 11 | // Client-side services, like feathers-memory, require paginate.default to be truthy. 12 | if (context.method === 'find') { 13 | const { params } = context 14 | const { query = {} } = params 15 | const isPaginated = params.paginate === true || hasOwn(query, '$limit') || hasOwn(query, '$skip') 16 | if (isPaginated) params.paginate = { default: true } 17 | } 18 | 19 | if (next) await next() 20 | 21 | if (context.method === 'find' && !context.result.data) { 22 | // context.result = { data: context.result } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /feathers-pinia-3/use-find-get/types.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue-demi' 2 | import type { AnyData, Params, Query } from '../types' 3 | import type { MostRecentQuery, PaginationStateQuery } from '../stores/types' 4 | 5 | export interface UseFindPage { 6 | limit: Ref 7 | skip: Ref 8 | } 9 | 10 | export interface UseFindGetDeps { 11 | service: any 12 | } 13 | 14 | export interface UseFindParams extends Params { 15 | query: Query 16 | qid?: string 17 | } 18 | 19 | export interface UseFindOptions { 20 | paginateOn?: 'client' | 'server' | 'hybrid' 21 | pagination?: UseFindPage 22 | debounce?: number 23 | immediate?: boolean 24 | watch?: boolean 25 | } 26 | 27 | export interface UseGetParams extends Params { 28 | query?: Query 29 | immediate?: boolean 30 | watch?: boolean 31 | } 32 | 33 | export interface CurrentQuery extends MostRecentQuery { 34 | qid: string 35 | ids: number[] 36 | items: M[] 37 | total: number 38 | queriedAt: number 39 | queryState: PaginationStateQuery 40 | } 41 | -------------------------------------------------------------------------------- /feathers-pinia-3/localstorage/storage-sync.ts: -------------------------------------------------------------------------------- 1 | import { computed, watch } from 'vue-demi' 2 | import { _ } from '@feathersjs/commons' 3 | import debounce from 'just-debounce' 4 | 5 | // Writes data to localStorage 6 | export function writeToStorage(id: string, data: any, storage: any) { 7 | const compressed = JSON.stringify(data) 8 | storage.setItem(id, compressed) 9 | } 10 | 11 | // Moves data from localStorage into the store 12 | export function hydrateStore(store: any, storage: any) { 13 | const data = storage.getItem(store.$id) 14 | if (data) { 15 | const hydrationData = JSON.parse(data as string) || {} 16 | Object.assign(store, hydrationData) 17 | } 18 | } 19 | 20 | /** 21 | * 22 | * @param store pinia store 23 | * @param keys an array of keys to watch and write to localStorage. 24 | */ 25 | export function syncWithStorage(store: any, stateKeys: Array, storage: Storage = window.localStorage) { 26 | hydrateStore(store, storage) 27 | 28 | const debouncedWrite = debounce(writeToStorage, 500) 29 | const toWatch = computed(() => _.pick(store, ...stateKeys)) 30 | 31 | watch(toWatch, (val) => debouncedWrite(store.$id, val, storage), { deep: true }) 32 | } 33 | -------------------------------------------------------------------------------- /assets/reset.css: -------------------------------------------------------------------------------- 1 | /* From https://www.joshwcomeau.com/css/custom-css-reset/ */ 2 | 3 | /* 4 | 1. Use a more-intuitive box-sizing model. 5 | */ 6 | *, 7 | *::before, 8 | *::after { 9 | box-sizing: border-box; 10 | } 11 | 12 | /* 13 | 2. Remove default margin 14 | */ 15 | * { 16 | margin: 0; 17 | } 18 | 19 | /* 20 | 3. Allow percentage-based heights in the application 21 | */ 22 | html, 23 | body { 24 | height: 100%; 25 | } 26 | 27 | /* 28 | Typographic tweaks! 29 | 4. Add accessible line-height 30 | 5. Improve text rendering 31 | */ 32 | body { 33 | line-height: 1.5; 34 | -webkit-font-smoothing: antialiased; 35 | } 36 | 37 | /* 38 | 6. Improve media defaults 39 | */ 40 | img, 41 | picture, 42 | video, 43 | canvas, 44 | svg { 45 | display: block; 46 | max-width: 100%; 47 | } 48 | 49 | /* 50 | 7. Remove built-in form typography styles 51 | */ 52 | input, 53 | button, 54 | textarea, 55 | select { 56 | font: inherit; 57 | } 58 | 59 | /* 60 | 8. Avoid text overflows 61 | */ 62 | p, 63 | h1, 64 | h2, 65 | h3, 66 | h4, 67 | h5, 68 | h6 { 69 | overflow-wrap: break-word; 70 | } 71 | 72 | /* 73 | 9. Create a root stacking context 74 | */ 75 | #root, 76 | #__next { 77 | isolation: isolate; 78 | } -------------------------------------------------------------------------------- /components/DeleteDialog.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 42 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/event-locks.ts: -------------------------------------------------------------------------------- 1 | import type { Id } from '@feathersjs/feathers' 2 | import type { MaybeArray } from '../types' 3 | import type { EventLocks, EventName } from './types' 4 | import { del, reactive, set } from 'vue-demi' 5 | import { getArray } from '../utils' 6 | 7 | export const useServiceEventLocks = () => { 8 | const eventLocks = reactive({ 9 | created: {}, 10 | patched: {}, 11 | updated: {}, 12 | removed: {}, 13 | }) 14 | 15 | function toggleEventLock(data: MaybeArray, event: EventName) { 16 | const { items: ids } = getArray(data) 17 | ids.forEach((id) => { 18 | const currentLock = eventLocks[event][id] 19 | if (currentLock) { 20 | clearEventLock(data, event) 21 | } else { 22 | set(eventLocks[event], id, true) 23 | // auto-clear event lock after 250 ms 24 | setTimeout(() => { 25 | clearEventLock(data, event) 26 | }, 250) 27 | } 28 | }) 29 | } 30 | function clearEventLock(data: MaybeArray, event: EventName) { 31 | const { items: ids } = getArray(data) 32 | ids.forEach((id) => { 33 | del(eventLocks[event], id) 34 | }) 35 | } 36 | return { eventLocks, toggleEventLock, clearEventLock } 37 | } 38 | -------------------------------------------------------------------------------- /feathers-pinia-3/hooks/3-sync-store.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext, NextFunction } from '@feathersjs/feathers' 2 | import { restoreTempIds } from '../utils' 3 | 4 | export const syncStore = () => async (context: HookContext, next: NextFunction) => { 5 | const { method, params } = context 6 | const store = context.service.store 7 | 8 | if (method === 'patch' && params.data) context.data = params.data 9 | 10 | if (next) await next() 11 | 12 | if (!context.params.skipStore) { 13 | if (method === 'remove') { 14 | store.removeFromStore(context.result) 15 | } else if (method === 'create') { 16 | const restoredTempIds = restoreTempIds(context.data, context.result) 17 | context.result = store.createInStore(restoredTempIds) 18 | } else if (method === 'find' && Array.isArray(context.result.data)) { 19 | context.result.data = store.createInStore(context.result.data) 20 | } else { 21 | context.result = store.createInStore(context.result) 22 | } 23 | 24 | // Update pagination based on the qid 25 | if (method === 'find' && context.result.total) { 26 | const { qid = 'default', query, preserveSsr = false } = context.params 27 | store.updatePaginationForQuery({ qid, response: context.result, query, preserveSsr }) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /feathers-pinia-3/hooks/1-set-pending.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext, NextFunction } from '@feathersjs/feathers' 2 | 3 | /** 4 | * Controls pending state 5 | */ 6 | export const setPending = () => async (context: HookContext, next: NextFunction) => { 7 | const store = context.service.store 8 | let unsetPending 9 | 10 | if (!store.isSsr) { 11 | const method = context.method === 'find' ? (context.params.query?.$limit === 0 ? 'count' : 'find') : context.method 12 | 13 | store.setPending(method, true) 14 | if (context.id != null && method !== 'get') 15 | store.setPendingById(context.id, method, true) 16 | 17 | const isTemp = context.data?.__isTemp 18 | const tempId = context.data?.__tempId 19 | if (isTemp && method === 'create') 20 | store.setPendingById(context.data.__tempId, method, true) 21 | 22 | unsetPending = () => { 23 | store.setPending(method, false) 24 | const id = context.id != null ? context.id : tempId 25 | if (id != null && method !== 'get') 26 | store.setPendingById(id, method, false) 27 | } 28 | } 29 | 30 | try { 31 | await next() 32 | } 33 | catch (error) { 34 | if (unsetPending) 35 | unsetPending() 36 | 37 | throw error 38 | } 39 | 40 | if (unsetPending) 41 | unsetPending() 42 | } 43 | -------------------------------------------------------------------------------- /pages/app/contacts/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 35 | -------------------------------------------------------------------------------- /feathers-pinia-3/utils/define-properties.ts: -------------------------------------------------------------------------------- 1 | import type { AnyData } from '../types' 2 | 3 | /** 4 | * Defines all provided properties as non-enumerable, configurable, values 5 | */ 6 | export const defineValues = (data: M, properties: D) => { 7 | Object.keys(properties).forEach((key) => { 8 | Object.defineProperty(data, key, { 9 | enumerable: false, 10 | configurable: true, 11 | value: properties[key], 12 | }) 13 | }) 14 | return data 15 | } 16 | 17 | /** 18 | * Defines all provided properties as non-enumerable, configurable, getters 19 | */ 20 | export const defineGetters = (data: M, properties: D) => { 21 | Object.keys(properties).forEach((key) => { 22 | Object.defineProperty(data, key, { 23 | enumerable: false, 24 | configurable: true, 25 | get: properties[key], 26 | }) 27 | }) 28 | return data 29 | } 30 | 31 | /** 32 | * Defines all provided properties as non-enumerable, configurable, setters 33 | */ 34 | export const defineSetters = (data: M, properties: D) => { 35 | Object.keys(properties).forEach((key) => { 36 | Object.defineProperty(data, key, { 37 | enumerable: false, 38 | configurable: true, 39 | set: properties[key], 40 | }) 41 | }) 42 | return data 43 | } 44 | -------------------------------------------------------------------------------- /feathers-pinia-3/hooks/5-handle-find-ssr.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext, Id, NextFunction } from '@feathersjs/feathers' 2 | 3 | /** 4 | * Assures that the client reuses SSR-provided data instead of re-making the same query. 5 | * 6 | * Checks the `store.pagination` object to see if a query's results came from SSR-provided data. 7 | * If the data was from SSR, the SSR'd data is used and then set to `fromSSR = false` to allow 8 | * normal queries to happen again. 9 | */ 10 | export function handleFindSsr() { 11 | return async (context: HookContext, next: NextFunction) => { 12 | const store = context.service.store 13 | 14 | if (context.method === 'find') { 15 | const { params } = context 16 | const info = store.getQueryInfo(params) 17 | const qidData = store.pagination[info.qid] 18 | const queryData = qidData?.[info.queryId] 19 | const pageData = queryData?.[info.pageId as string] 20 | 21 | if (pageData?.ssr) { 22 | context.result = { 23 | data: pageData.ids.map((id: Id) => store.getFromStore(id).value), 24 | limit: pageData.pageParams.$limit, 25 | skip: pageData.pageParams.$skip, 26 | total: queryData.total, 27 | fromSsr: true, 28 | } 29 | if (!params.preserveSsr) 30 | store.unflagSsr(params) 31 | } 32 | } 33 | 34 | if (next) 35 | await next() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /feathers-pinia-3/utils/deep-unref.ts: -------------------------------------------------------------------------------- 1 | import { MaybeRef } from '@vueuse/core' 2 | import { unref, isRef } from 'vue-demi' 3 | 4 | const isObject = (val: Record) => val !== null && typeof val === 'object' 5 | const isArray = Array.isArray 6 | 7 | /** 8 | * Deeply unref a value, recursing into objects and arrays. 9 | * 10 | * Adapted from https://github.com/DanHulton/vue-deepunref 11 | */ 12 | export function deepUnref(val: MaybeRef>) { 13 | const checkedVal: any = isRef(val) ? unref(val) : val 14 | 15 | if (!isObject(checkedVal)) { 16 | return checkedVal 17 | } 18 | 19 | if (isArray(checkedVal)) { 20 | return unrefArray(checkedVal) 21 | } 22 | 23 | return unrefObject(checkedVal) 24 | } 25 | 26 | // Unref a value, recursing into it if it's an object. 27 | const smartUnref = (val: Record) => { 28 | // Non-ref object? Go deeper! 29 | if (val !== null && !isRef(val) && typeof val === 'object') { 30 | return deepUnref(val) 31 | } 32 | 33 | return unref(val) 34 | } 35 | 36 | // Unref an array, recursively. 37 | const unrefArray = (arr: any) => arr.map(smartUnref) 38 | 39 | // Unref an object, recursively. 40 | const unrefObject = (obj: Record) => { 41 | const unreffed: Record = {} 42 | 43 | Object.keys(obj).forEach((key) => { 44 | unreffed[key] = smartUnref(obj[key]) 45 | }) 46 | 47 | return unreffed 48 | } 49 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 48 | -------------------------------------------------------------------------------- /pages/app/tweets.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 62 | -------------------------------------------------------------------------------- /feathers-pinia-3/hooks/8-patch-diffs.ts: -------------------------------------------------------------------------------- 1 | import type { HookContext, NextFunction } from '@feathersjs/feathers' 2 | import fastCopy from 'fast-copy' 3 | import { diff, pickDiff } from '../utils' 4 | 5 | export const patchDiffing = () => async (context: HookContext, next: NextFunction) => { 6 | const { method, data, params, id } = context 7 | const store = context.service.store 8 | 9 | let rollbackData: any 10 | let clone: any 11 | const shouldDiff = method === 'patch' && !params.data && (data.__isClone || params.diff) 12 | 13 | if (shouldDiff) { 14 | clone = data 15 | const original = store.getFromStore(id).value 16 | const diffedData = diff(original, clone, params.diff) 17 | rollbackData = fastCopy(original) 18 | 19 | // Do eager updating. 20 | if (params.eager !== false) data.commit(diffedData) 21 | 22 | // Always include matching values from `params.with`. 23 | if (params.with) { 24 | const dataFromWith = pickDiff(clone, params.with) 25 | // If params.with was an object, merge the values into dataFromWith 26 | if (typeof params.with !== 'string' && !Array.isArray(params.with)) Object.assign(dataFromWith, params.with) 27 | 28 | Object.assign(diffedData, dataFromWith) 29 | } 30 | 31 | context.data = diffedData 32 | 33 | // If diff is empty, return the clone without making a request. 34 | if (Object.keys(context.data).length === 0) context.result = clone 35 | } else { 36 | context.data = fastCopy(data) 37 | } 38 | 39 | try { 40 | await next() 41 | } catch (error) { 42 | if (shouldDiff) { 43 | // If saving fails, reverse the eager update 44 | clone && clone.commit(rollbackData) 45 | } 46 | throw error 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/utils-custom-operators.ts: -------------------------------------------------------------------------------- 1 | import { createEqualsOperation } from 'sift' 2 | 3 | // Simulate SQL's case-sensitive LIKE. 4 | // A combination of answers from https://stackoverflow.com/questions/1314045/emulating-sql-like-in-javascript 5 | export function like(value: string, search: string, regexOptions = 'g') { 6 | const specials = ['/', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\'] 7 | // Remove specials 8 | search = search.replace(new RegExp(`(\\${specials.join('|\\')})`, regexOptions), '\\$1') 9 | // Replace % and _ with equivalent regex 10 | search = search.replace(/%/g, '.*').replace(/_/g, '.') 11 | // Check matches 12 | return RegExp(`^${search}$`, regexOptions).test(value) 13 | } 14 | 15 | // Simulate PostgreSQL's case-insensitive ILIKE 16 | export function iLike(str: string, search: string) { 17 | return like(str, search, 'ig') 18 | } 19 | 20 | export const $like = (params: any, ownerQuery: any, options: any) => { 21 | return createEqualsOperation((value: any) => like(value, params), ownerQuery, options) 22 | } 23 | 24 | export const $notLike = (params: any, ownerQuery: any, options: any) => { 25 | return createEqualsOperation((value: any) => !like(value, params), ownerQuery, options) 26 | } 27 | 28 | export const $ilike = (params: any, ownerQuery: any, options: any) => { 29 | return createEqualsOperation((value: any) => iLike(value, params), ownerQuery, options) 30 | } 31 | 32 | const $notILike = (params: any, ownerQuery: any, options: any) => { 33 | return createEqualsOperation((value: any) => !iLike(value, params), ownerQuery, options) 34 | } 35 | 36 | export const sqlOperations = { 37 | $like, 38 | $notLike, 39 | $notlike: $notLike, 40 | $ilike, 41 | $iLike: $ilike, 42 | $notILike, 43 | } 44 | -------------------------------------------------------------------------------- /components/TweetComposer.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 53 | -------------------------------------------------------------------------------- /public/feathersjs.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt 3 App with Feathers-Pinia 2 | 3 | Look at the [nuxt 3 documentation](https://v3.nuxtjs.org) to learn more. 4 | 5 | In order to use this repo, you need a working FeathersJS server. You can find one at the [feathers-pinia-api](https://github.com/marshallswain/feathers-pinia-api) repo. 6 | 7 | # Configuration 8 | 9 | - [Feathers-Pinia](https://v1.feathers-pinia.pages.dev) with two example services. 10 | - [@antfu/eslint-config](https://github.com/antfu/eslint-config) 11 | - [UnoCSS](https://github.com/unocss/unocss) 12 | - [Iconify with UnoCSS](https://github.com/unocss/unocss/tree/main/packages/preset-icons/) 13 | 14 | ## Getting Started 15 | 16 | 1. Make sure you have [NodeJS](https://nodejs.org/) and [npm](https://www.npmjs.com/) installed. 17 | 2. Install [feathers-pinia-api](https://github.com/marshallswain/feathers-pinia-api) 18 | 3. Install your dependencies 19 | 20 | ``` 21 | cd path/to/feathers-pinia-nuxt3 22 | npm install 23 | ``` 24 | 25 | If you get a 404 'feathers-pinia-api-0.0.0.tgz' is not in this registry error, run npm run bundle:client in your feathers-pinia-api install and make sure it's running when you do this install. 26 | 27 | If you get a integrity checksum failed error, try deleting your package-lock.json file. 28 | 29 | 4. Run the app 30 | 31 | ``` 32 | npm run dev 33 | ``` 34 | 35 | 5. Visit the web site 36 | 37 | ``` 38 | http://localhost:3000 39 | 40 | ## Production 41 | 42 | Build the application for production: 43 | 44 | ```bash 45 | npm run build 46 | ``` 47 | 48 | Locally preview production build: 49 | 50 | ```bash 51 | npm run preview 52 | ``` 53 | 54 | Checkout the [deployment documentation](https://v3.nuxtjs.org/guide/deploy/presets) for more information. 55 | -------------------------------------------------------------------------------- /feathers-pinia-3/use-find-get/utils.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeRef } from '@vueuse/core' 2 | import type { Ref } from 'vue-demi' 3 | import { _ } from '@feathersjs/commons' 4 | import { unref } from 'vue-demi' 5 | import type { Params, Query } from '../types' 6 | import type { UseFindParams } from './types' 7 | 8 | export function makeParamsWithoutPage(params: MaybeRef) { 9 | params = unref(params) 10 | const query = _.omit(params.query, '$limit', '$skip') 11 | const newParams = _.omit(params, 'query', 'store') 12 | return { ...newParams, query } 13 | } 14 | 15 | // Updates the _params with everything from _newParams except `$limit` and `$skip` 16 | export function updateParamsExcludePage(_params: Ref, _newParams: MaybeRef) { 17 | _params.value.query = { 18 | ...unref(_newParams).query, 19 | ..._.pick(unref(_params).query, '$limit', '$skip'), 20 | } 21 | } 22 | 23 | export function getIdsFromQueryInfo(pagination: any, queryInfo: any): any[] { 24 | const { queryId, pageId } = queryInfo 25 | const queryLevel = pagination[queryId] 26 | const pageLevel = queryLevel && queryLevel[pageId] 27 | const ids = pageLevel && pageLevel.ids 28 | 29 | return ids || [] 30 | } 31 | 32 | /** 33 | * A wrapper for findInStore that can return server-paginated data 34 | */ 35 | export function itemsFromPagination(store: any, service: any, params: Params) { 36 | const qid = params.qid || 'default' 37 | const pagination = store.pagination[qid] || {} 38 | const queryInfo = store.getQueryInfo(params) 39 | const ids = getIdsFromQueryInfo(pagination, queryInfo) 40 | const items = ids 41 | .map((id) => { 42 | const fromStore = service.getFromStore(id).value 43 | return fromStore 44 | }) 45 | .filter(i => i) // filter out undefined values 46 | return items 47 | } 48 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/event-queue-promise.ts: -------------------------------------------------------------------------------- 1 | import { watch } from 'vue-demi' 2 | 3 | type EventName = 'created' | 'updated' | 'patched' | 'removed' 4 | 5 | interface QueuePromiseState { 6 | promise: Promise 7 | isResolved: boolean 8 | getter: 'isCreatePending' | 'isUpdatePending' | 'isPatchPending' | 'isRemovePending' 9 | } 10 | 11 | const events = ['created', 'updated', 'patched', 'removed'] 12 | const state: { [key: string]: QueuePromiseState } = {} 13 | 14 | export const makeGetterName = (event: EventName) => 15 | `is${event.slice(0, 1).toUpperCase()}${event.slice(1, event.length - 1)}Pending` 16 | 17 | export const makeState = (event: EventName) => ({ 18 | promise: null, 19 | isResolved: false, 20 | getter: makeGetterName(event), 21 | }) 22 | export const resetState = () => { 23 | events.forEach((e) => { 24 | delete state[e] 25 | }) 26 | } 27 | /** 28 | * Creates or reuses a promise for each event type, like "created". The promise 29 | * resolves when the matching `isPending` attribute, like "isCreatePending" becomes 30 | * false. 31 | * @param store 32 | * @param event 33 | * @returns 34 | */ 35 | export function useQueuePromise(store: any, event: EventName) { 36 | state[event] = state[event] || makeState(event) 37 | 38 | if (!state[event].promise || state[event].isResolved) { 39 | state[event].promise = new Promise((resolve) => { 40 | const stopWatching = watch( 41 | () => store[state[event].getter], 42 | async (isPending) => { 43 | if (!isPending) { 44 | setTimeout(() => { 45 | stopWatching() 46 | state[event].isResolved = true 47 | resolve(state[event].isResolved) 48 | }, 0) 49 | } 50 | }, 51 | { immediate: true }, 52 | ) 53 | }) 54 | } 55 | return state[event].promise 56 | } 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "nuxt build", 5 | "dev": "nuxt dev", 6 | "generate": "nuxt generate", 7 | "preview": "nuxt preview", 8 | "postinstall": "nuxt prepare", 9 | "lint": "eslint .", 10 | "lint:fix": "eslint . --fix" 11 | }, 12 | "devDependencies": { 13 | "@antfu/eslint-config": "^0.38.4", 14 | "@faker-js/faker": "^7.6.0", 15 | "@feathersjs/authentication-client": "^5.0.4", 16 | "@feathersjs/client": "^5.0.4", 17 | "@feathersjs/feathers": "^5.0.4", 18 | "@feathersjs/rest-client": "^5.0.4", 19 | "@feathersjs/socketio-client": "^5.0.4", 20 | "@formkit/addons": "^0.16.4", 21 | "@formkit/auto-animate": "1.0.0-beta.6", 22 | "@formkit/i18n": "^0.16.4", 23 | "@formkit/icons": "^0.16.4", 24 | "@formkit/nuxt": "^0.16.4", 25 | "@formkit/themes": "0.17.0-e457a82", 26 | "@formkit/vue": "^0.16.4", 27 | "@iconify/json": "^2.2.50", 28 | "@iconify/tailwind": "^0.1.2", 29 | "@nuxt/devtools": "^0.4.0", 30 | "@nuxtjs/color-mode": "^3.2.0", 31 | "@nuxtjs/tailwindcss": "^6.6.6", 32 | "@pinia/nuxt": "^0.4.8", 33 | "@unocss/nuxt": "^0.51.4", 34 | "@unocss/preset-icons": "^0.51.4", 35 | "@vuelidate/core": "^2.0.2", 36 | "@vuelidate/validators": "^2.0.2", 37 | "@vueuse/core": "^10.0.2", 38 | "@vueuse/nuxt": "^10.0.2", 39 | "daisy-ui-kit": "^1.0.7", 40 | "daisyui": "^2.51.5", 41 | "eslint": "^8.38.0", 42 | "fast-copy": "^3.0.1", 43 | "feathers-pinia": "3.0.0-pre.12", 44 | "feathers-pinia-api": "http://localhost:3030/feathers-pinia-api-0.0.5.tgz", 45 | "isomorphic-mongo-objectid": "^1.0.9", 46 | "jwt-decode": "^3.1.2", 47 | "nuxt": "^3.4.1", 48 | "pinia": "^2.0.34", 49 | "socket.io-client": "^4.6.1", 50 | "typescript": "^5.0.4", 51 | "vue": "^3.2.47" 52 | }, 53 | "dependencies": { 54 | "@feathersjs/commons": "^5.0.4", 55 | "nuxt-feathers-pinia": "^0.0.3", 56 | "ofetch": "^1.0.1", 57 | "vue-demi": "^0.14.0" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /plugins/1.feathers.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from 'feathers-pinia-api' 2 | 3 | // rest imports for the server 4 | import { $fetch } from 'ofetch' 5 | import rest from '@feathersjs/rest-client' 6 | 7 | // socket.io imports for the browser 8 | import socketio from '@feathersjs/socketio-client' 9 | import io from 'socket.io-client' 10 | import { OFetch, createPiniaClient } from '~~/feathers-pinia-3' 11 | import { timeout } from '~/feathers-pinia-3/utils' 12 | 13 | /** 14 | * Creates a Feathers Rest client for the SSR server and a Socket.io client for the browser. 15 | * Also provides a cookie-storage adapter for JWT SSR using Nuxt APIs. 16 | */ 17 | export default defineNuxtPlugin(async (nuxt) => { 18 | const host = import.meta.env.VITE_MYAPP_API_URL as string || 'http://localhost:3030' 19 | 20 | // Store JWT in a cookie for SSR. 21 | const storageKey = 'feathers-jwt' 22 | const jwt = useCookie(storageKey) 23 | const storage = { 24 | getItem: () => jwt.value, 25 | setItem: (key: string, val: string) => jwt.value = val, 26 | removeItem: () => jwt.value = null, 27 | } 28 | 29 | // Use Rest for the SSR Server and socket.io for the browser 30 | const connection = process.server 31 | ? rest(host).fetch($fetch, OFetch) 32 | : socketio(io(host, { transports: ['websocket'] })) 33 | 34 | // create the api client 35 | const feathers = createClient(connection, { storage, storageKey }) 36 | const api = createPiniaClient(feathers, { 37 | pinia: nuxt.$pinia, 38 | idField: '_id', 39 | ssr: !!process.server, 40 | whitelist: ['$regex'], 41 | paramsForServer: [], 42 | services: { 43 | users: { 44 | setupInstance: setupUser, 45 | }, 46 | tasks: { 47 | skipGetIfExists: true, 48 | }, 49 | }, 50 | }) 51 | 52 | feathers.service('contacts').hooks({ 53 | around: { 54 | all: [ 55 | async (context, next) => { 56 | await timeout(1000) 57 | await next() 58 | }, 59 | ], 60 | }, 61 | }) 62 | 63 | return { 64 | provide: { api }, 65 | } 66 | }) 67 | -------------------------------------------------------------------------------- /formkit.daisyui.ts: -------------------------------------------------------------------------------- 1 | const textClassification = { 2 | input: ` 3 | $reset 4 | input 5 | input-bordered 6 | w-full 7 | focus-within:input-primary 8 | formkit-invalid:input-error 9 | `, 10 | label: 'font-bold text-base formkit-invalid:text-red-500 block mb-0.5', 11 | } 12 | const buttonClassification = { 13 | input: '$reset btn btn-primary', 14 | } 15 | 16 | export default { 17 | // the global key will apply to all inputs 18 | 'global': { 19 | help: 'text-xs text-gray-500 m-1', 20 | message: '$reset text-error text-xs m-1', 21 | label: '$reset label-text ml-1', 22 | outer: '$reset my-2', 23 | }, 24 | 'button': buttonClassification, 25 | 'date': textClassification, 26 | 'datetime-local': textClassification, 27 | 'checkbox': { 28 | input: '$reset checkbox checkbox-accent', 29 | inner: '$reset inline', 30 | label: '$reset ml-2 label-text', 31 | legend: '$reset font-bold px-1', 32 | fieldset: '$reset card card-bordered border-accent p-2', 33 | wrapper: '$reset cursor-pointer flex items-center justify-start max-w-fit', 34 | }, 35 | 'email': textClassification, 36 | 'month': textClassification, 37 | 'number': textClassification, 38 | 'password': textClassification, 39 | 'radio': { 40 | input: '$reset radio radio-accent', 41 | inner: '$reset inline', 42 | label: '$reset ml-2 label-text', 43 | legend: '$reset font-bold px-1', 44 | fieldset: '$reset card card-bordered border-accent p-2', 45 | wrapper: '$reset cursor-pointer flex items-center justify-start max-w-fit', 46 | }, 47 | 'range': { 48 | input: '$reset range range-secondary', 49 | }, 50 | 'search': textClassification, 51 | 'select': textClassification, 52 | 'submit': buttonClassification, 53 | 'tel': textClassification, 54 | 'text': textClassification, 55 | 'textarea': { 56 | input: ` 57 | $reset 58 | textarea 59 | input-bordered 60 | focus-within:input-info 61 | formkit-invalid:input-error 62 | `, 63 | }, 64 | 'time': textClassification, 65 | 'url': textClassification, 66 | 'week': textClassification, 67 | } 68 | -------------------------------------------------------------------------------- /feathers-pinia-3/use-find-get/utils-pagination.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue-demi' 2 | import { computed } from 'vue-demi' 3 | import { timeout } from '../utils' 4 | 5 | interface Options { 6 | limit: Ref 7 | skip: Ref 8 | total: Ref 9 | request?: any 10 | } 11 | 12 | export function usePageData(options: Options) { 13 | const { limit, skip, total, request } = options 14 | /** 15 | * The number of pages available based on the results returned in the latestQuery prop. 16 | */ 17 | const pageCount = computed(() => { 18 | if (total.value) 19 | return Math.ceil(total.value / limit.value) 20 | else return 1 21 | }) 22 | 23 | // Uses Math.floor so we can't land on a non-integer page like 1.4 24 | const currentPage = computed({ 25 | set(pageNumber: number) { 26 | if (pageNumber < 1) 27 | pageNumber = 1 28 | else if (pageNumber > pageCount.value) 29 | pageNumber = pageCount.value 30 | const newSkip = limit.value * Math.floor(pageNumber - 1) 31 | skip.value = newSkip 32 | }, 33 | get() { 34 | const skipVal = skip.value || 0 35 | return pageCount.value === 0 ? 0 : Math.floor(skipVal / limit.value + 1) 36 | }, 37 | }) 38 | 39 | const canPrev = computed(() => { 40 | return currentPage.value - 1 > 0 41 | }) 42 | const canNext = computed(() => { 43 | return currentPage.value < pageCount.value 44 | }) 45 | 46 | const wait = async () => { 47 | if (request?.value) 48 | await request.value 49 | } 50 | const toStart = async () => { 51 | currentPage.value = 1 52 | await timeout(0) 53 | return wait() 54 | } 55 | const toEnd = async () => { 56 | currentPage.value = pageCount.value 57 | await timeout(0) 58 | return wait() 59 | } 60 | const toPage = async (page: number) => { 61 | currentPage.value = page 62 | await timeout(0) 63 | return wait() 64 | } 65 | const next = async () => { 66 | currentPage.value++ 67 | await timeout(0) 68 | return wait() 69 | } 70 | const prev = async () => { 71 | currentPage.value-- 72 | await timeout(0) 73 | return wait() 74 | } 75 | 76 | return { pageCount, currentPage, canPrev, canNext, toStart, toEnd, toPage, next, prev } 77 | } 78 | -------------------------------------------------------------------------------- /components/TaskListItem.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 85 | -------------------------------------------------------------------------------- /feathers-pinia-3/modeling/use-model-instance.ts: -------------------------------------------------------------------------------- 1 | import type { CloneOptions } from '../stores' 2 | import type { AnyData, ById, Params } from '../types' 3 | import type { BaseModelData, StoreInstanceProps, ModelInstanceData } from './types' 4 | import ObjectID from 'isomorphic-mongo-objectid' 5 | import { defineValues } from '../utils/define-properties' 6 | 7 | interface UseModelInstanceOptions { 8 | idField: string 9 | clonesById: ById 10 | clone: (item: M, data?: {}, options?: CloneOptions) => M 11 | commit: (item: M, data?: Partial) => M 12 | reset: (item: M, data?: {}) => M 13 | createInStore: (data: M | M[]) => M | M[] 14 | removeFromStore: (data: M | M[] | null, params?: Params) => M | M[] | null 15 | } 16 | 17 | export const useModelInstance = ( 18 | data: ModelInstanceData, 19 | options: UseModelInstanceOptions, 20 | ) => { 21 | if (data.__isStoreInstance) return data 22 | 23 | const { idField, clonesById, clone, commit, reset, createInStore, removeFromStore } = options 24 | const __isClone = data.__isClone || false 25 | 26 | // instance.__isTemp 27 | Object.defineProperty(data, '__isTemp', { 28 | configurable: true, 29 | enumerable: false, 30 | get() { 31 | return this[this.__idField] == null 32 | }, 33 | }) 34 | 35 | // BaseModel properties 36 | const asBaseModel = defineValues(data, { 37 | __isStoreInstance: true, 38 | __isClone, 39 | __idField: idField, 40 | __tempId: data[idField] == null && data.__tempId == null ? new ObjectID().toString() : data.__tempId || undefined, 41 | hasClone(this: M) { 42 | const id = this[this.__idField] || this.__tempId 43 | const item = clonesById[id] 44 | return item || null 45 | }, 46 | clone(this: M, data: Partial = {}, options: CloneOptions = {}) { 47 | const item = clone(this, data, options) 48 | return item 49 | }, 50 | commit(this: M, data: Partial = {}) { 51 | const item = commit(this, data) 52 | return item 53 | }, 54 | reset(this: M, data: Partial = {}) { 55 | const item = reset(this, data) 56 | return item 57 | }, 58 | createInStore(this: M) { 59 | const item = createInStore(this) 60 | return item 61 | }, 62 | removeFromStore(this: M) { 63 | const item = removeFromStore(this) 64 | return item 65 | }, 66 | }) as M & BaseModelData & StoreInstanceProps 67 | 68 | return asBaseModel 69 | } 70 | -------------------------------------------------------------------------------- /components/TaskList.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 73 | 74 | 79 | -------------------------------------------------------------------------------- /feathers-pinia-3/modeling/use-feathers-instance.ts: -------------------------------------------------------------------------------- 1 | import { BadRequest } from '@feathersjs/errors' 2 | import type { FeathersService, Params } from '@feathersjs/feathers' 3 | import type { AnyData } from '../types' 4 | import { defineValues, defineGetters } from '../utils/define-properties' 5 | import type { ServiceInstanceProps } from './types' 6 | import type { PiniaService } from '../create-pinia-service' 7 | 8 | type Service = FeathersService | PiniaService 9 | 10 | export interface useServiceInstanceOptions { 11 | service: S 12 | store: any 13 | } 14 | 15 | export const useServiceInstance = ( 16 | data: M, 17 | options: useServiceInstanceOptions, 18 | ) => { 19 | if (data.__isServiceInstance) return data 20 | 21 | const { service, store } = options 22 | const merge = (data: M, toMerge: AnyData) => Object.assign(data, toMerge) 23 | 24 | defineGetters(data, { 25 | isPending() { 26 | return this.isCreatePending || this.isPatchPending || this.isRemovePending 27 | }, 28 | isSavePending() { 29 | return this.isCreatePending || this.isPatchPending 30 | }, 31 | isCreatePending() { 32 | return !!(store.createPendingById[this[store.idField]] || store.createPendingById[this.__tempId]) 33 | }, 34 | isPatchPending() { 35 | return !!store.patchPendingById[this[store.idField]] 36 | }, 37 | isRemovePending() { 38 | return !!store.removePendingById[this[store.idField]] 39 | }, 40 | } as any) 41 | 42 | defineValues(data, { 43 | __isServiceInstance: true, 44 | save(this: M, params?: P) { 45 | const id = this[store.idField] 46 | return id != null ? this.patch(params) : this.create(params) 47 | }, 48 | create(this: M, params?: P): Promise { 49 | return service.create(this, params).then((result) => merge(this, result)) 50 | }, 51 | patch(this: M, params?: P): Promise { 52 | const id = this[store.idField] 53 | if (id === undefined) throw new BadRequest('the item has no id') 54 | return service.patch(id, this, params).then((result) => merge(this, result)) 55 | }, 56 | remove(this: M, params?: P): Promise { 57 | if (this.__isTemp) { 58 | store.removeFromStore(this.__tempId) 59 | return Promise.resolve(this) 60 | } else { 61 | const id = this[store.idField] 62 | return service.remove(id, params).then((result) => merge(this, result)) 63 | } 64 | }, 65 | }) 66 | 67 | return data as M & ServiceInstanceProps 68 | } 69 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/all-storage-types.ts: -------------------------------------------------------------------------------- 1 | import type { AnyData, MakeCopyOptions } from '../types' 2 | import fastCopy from 'fast-copy' 3 | import { defineValues } from '../utils' 4 | import { useServiceTemps } from './temps' 5 | import { useServiceClones } from './clones' 6 | import { useServiceStorage } from './storage' 7 | 8 | interface UseAllStorageOptions { 9 | getIdField: (val: AnyData) => any 10 | setupInstance: any 11 | } 12 | 13 | export const useAllStorageTypes = (options: UseAllStorageOptions) => { 14 | const { getIdField, setupInstance } = options 15 | 16 | /** 17 | * Makes a copy of the Model instance with __isClone properly set 18 | * Private 19 | */ 20 | const makeCopy = (item: M, data: AnyData = {}, { isClone }: MakeCopyOptions) => { 21 | const copied = fastCopy(item) 22 | Object.assign(copied, data) 23 | // instance.__isTemp 24 | Object.defineProperty(copied, '__isTemp', { 25 | configurable: true, 26 | enumerable: false, 27 | get() { 28 | return this[this.__idField] == null 29 | }, 30 | }) 31 | const withExtras = defineValues(copied, { 32 | __isClone: isClone, 33 | __tempId: item.__tempId, 34 | }) 35 | return withExtras 36 | } 37 | 38 | // item storage 39 | const itemStorage = useServiceStorage({ 40 | getId: getIdField, 41 | beforeWrite: setupInstance, 42 | onRead: setupInstance, 43 | }) 44 | 45 | // temp item storage 46 | const { tempStorage, moveTempToItems } = useServiceTemps({ 47 | getId: (item) => item.__tempId, 48 | itemStorage, 49 | beforeWrite: setupInstance, 50 | onRead: setupInstance, 51 | }) 52 | 53 | // clones 54 | const { cloneStorage, clone, commit, reset, markAsClone } = useServiceClones({ 55 | itemStorage, 56 | tempStorage, 57 | makeCopy, 58 | beforeWrite: (item) => { 59 | markAsClone(item) 60 | return setupInstance(item) 61 | }, 62 | onRead: setupInstance, 63 | }) 64 | 65 | /** 66 | * Stores the provided item in the correct storage (itemStorage, tempStorage, or cloneStorage). 67 | * If an item has both an id and a tempId, it gets moved from tempStorage to itemStorage. 68 | * Private 69 | */ 70 | const addItemToStorage = (item: M) => { 71 | const id = getIdField(item) 72 | item = setupInstance(item) 73 | 74 | if (item.__isClone) return cloneStorage.merge(item) 75 | else if (id != null && item.__tempId != null) return moveTempToItems(item) 76 | else if (id != null) return itemStorage.merge(item) 77 | else if (tempStorage && item.__tempId != null) return tempStorage?.merge(item) 78 | 79 | return itemStorage.merge(item) 80 | } 81 | 82 | return { 83 | itemStorage, 84 | tempStorage, 85 | cloneStorage, 86 | clone, 87 | commit, 88 | reset, 89 | addItemToStorage, 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/use-data-store.ts: -------------------------------------------------------------------------------- 1 | import type { Query } from '@feathersjs/feathers' 2 | 3 | import { computed, unref } from 'vue-demi' 4 | import type { AnyData } from '../types' 5 | import { MaybeRef } from '@vueuse/core' 6 | import { useServiceLocal } from './local-queries' 7 | 8 | import { useAllStorageTypes } from './all-storage-types' 9 | import { useModelInstance } from '../modeling/use-model-instance' 10 | 11 | export interface UseDataStoreOptions { 12 | idField: string 13 | ssr?: MaybeRef 14 | customSiftOperators?: Record 15 | setupInstance?: any 16 | } 17 | 18 | const makeDefaultOptions = () => ({ 19 | skipGetIfExists: false, 20 | }) 21 | 22 | export const useDataStore = (_options: UseDataStoreOptions) => { 23 | const options = Object.assign({}, makeDefaultOptions(), _options) 24 | const { idField, customSiftOperators } = options 25 | 26 | function setupInstance(this: any, data: N) { 27 | const asBaseModel = useModelInstance(data, { 28 | idField, 29 | clonesById: cloneStorage.byId, 30 | clone, 31 | commit, 32 | reset, 33 | createInStore, 34 | removeFromStore, 35 | }) 36 | 37 | if (data.__isSetup) return asBaseModel 38 | else { 39 | const afterSetup = options.setupInstance ? options.setupInstance(asBaseModel) : asBaseModel 40 | Object.defineProperty(afterSetup, '__isSetup', { value: true }) 41 | return afterSetup 42 | } 43 | } 44 | 45 | // storage 46 | const { itemStorage, tempStorage, cloneStorage, clone, commit, reset, addItemToStorage } = useAllStorageTypes({ 47 | getIdField: (val: AnyData) => val[idField], 48 | setupInstance, 49 | }) 50 | 51 | const isSsr = computed(() => { 52 | const ssr = unref(options.ssr) 53 | return !!ssr 54 | }) 55 | 56 | function clearAll() { 57 | itemStorage.clear() 58 | tempStorage.clear() 59 | cloneStorage.clear() 60 | } 61 | 62 | // local data filtering 63 | const { findInStore, findOneInStore, countInStore, getFromStore, createInStore, patchInStore, removeFromStore } = 64 | useServiceLocal({ 65 | idField, 66 | itemStorage, 67 | tempStorage, 68 | cloneStorage, 69 | addItemToStorage, 70 | customSiftOperators, 71 | }) 72 | 73 | const store = { 74 | new: setupInstance, 75 | idField, 76 | isSsr, 77 | 78 | // items 79 | itemsById: itemStorage.byId, 80 | items: itemStorage.list, 81 | itemIds: itemStorage.ids, 82 | 83 | // temps 84 | tempsById: tempStorage.byId, 85 | temps: tempStorage.list, 86 | tempIds: tempStorage.ids, 87 | 88 | // clones 89 | clonesById: cloneStorage.byId, 90 | clones: cloneStorage.list, 91 | cloneIds: cloneStorage.ids, 92 | clone, 93 | commit, 94 | reset, 95 | 96 | // local queries 97 | findInStore, 98 | findOneInStore, 99 | countInStore, 100 | createInStore, 101 | getFromStore, 102 | patchInStore, 103 | removeFromStore, 104 | clearAll, 105 | } 106 | 107 | return store 108 | } 109 | -------------------------------------------------------------------------------- /pages/login.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 100 | -------------------------------------------------------------------------------- /components/Tweet.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 84 | -------------------------------------------------------------------------------- /components/AppNav.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 85 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/pending.ts: -------------------------------------------------------------------------------- 1 | import type { NullableId } from '@feathersjs/feathers' 2 | import type { Ref } from 'vue-demi' 3 | import type { RequestTypeById } from './types' 4 | import { computed, del, ref, set } from 'vue-demi' 5 | 6 | const defaultPending = () => ({ 7 | find: 0, 8 | count: 0, 9 | get: 0, 10 | create: 0, 11 | update: 0, 12 | patch: 0, 13 | remove: 0, 14 | }) 15 | 16 | export const useServicePending = () => { 17 | const isPending = ref(defaultPending()) 18 | 19 | const createPendingById = ref({}) as Ref> 20 | const updatePendingById = ref({}) as Ref> 21 | const patchPendingById = ref({}) as Ref> 22 | const removePendingById = ref({}) as Ref> 23 | 24 | const isFindPending = computed(() => { 25 | return isPending.value.find > 0 26 | }) 27 | 28 | const isCountPending = computed(() => { 29 | return isPending.value.count > 0 30 | }) 31 | 32 | const isGetPending = computed(() => { 33 | return isPending.value.get > 0 34 | }) 35 | 36 | const isCreatePending = computed(() => { 37 | return isPending.value.create > 0 || Object.keys(createPendingById.value).length > 0 38 | }) 39 | 40 | const isUpdatePending = computed(() => { 41 | return isPending.value.update > 0 || Object.keys(updatePendingById.value).length > 0 42 | }) 43 | 44 | const isPatchPending = computed(() => { 45 | return isPending.value.patch > 0 || Object.keys(patchPendingById.value).length > 0 46 | }) 47 | 48 | const isRemovePending = computed(() => { 49 | return isPending.value.remove > 0 || Object.keys(removePendingById.value).length > 0 50 | }) 51 | 52 | function setPending(method: 'find' | 'count' | 'get' | 'create' | 'update' | 'patch' | 'remove', value: boolean) { 53 | if (value) isPending.value[method]++ 54 | else isPending.value[method]-- 55 | } 56 | 57 | function setPendingById(id: NullableId, method: RequestTypeById, val: boolean) { 58 | if (id == null) return 59 | 60 | let place 61 | 62 | if (method === 'create') place = createPendingById.value 63 | else if (method === 'update') place = updatePendingById.value 64 | else if (method === 'patch') place = patchPendingById.value 65 | else if (method === 'remove') place = removePendingById.value 66 | 67 | if (val) set(place, id, true) 68 | else del(place, id) 69 | } 70 | 71 | function unsetPendingById(...ids: NullableId[]) { 72 | ids.forEach((id) => { 73 | if (id == null) return 74 | del(createPendingById.value, id) 75 | del(updatePendingById.value, id) 76 | del(patchPendingById.value, id) 77 | del(removePendingById.value, id) 78 | }) 79 | } 80 | 81 | function clearAllPending() { 82 | isPending.value = defaultPending() 83 | 84 | createPendingById.value = {} 85 | updatePendingById.value = {} 86 | patchPendingById.value = {} 87 | removePendingById.value = {} 88 | } 89 | 90 | return { 91 | isPending, 92 | createPendingById, 93 | updatePendingById, 94 | patchPendingById, 95 | removePendingById, 96 | isFindPending, 97 | isCountPending, 98 | isGetPending, 99 | isCreatePending, 100 | isUpdatePending, 101 | isPatchPending, 102 | isRemovePending, 103 | setPending, 104 | setPendingById, 105 | unsetPendingById, 106 | clearAllPending, 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /feathers-pinia-3/types.ts: -------------------------------------------------------------------------------- 1 | import type { Params as FeathersParams, Id } from '@feathersjs/feathers' 2 | import type { MaybeRef } from '@vueuse/core' 3 | import type { ServiceInstance } from './modeling' 4 | import type { PaginationStateQuery } from './stores' 5 | 6 | export type MaybeArray = T | T[] 7 | export type AnyData = Record 8 | export type AnyDataOrArray = MaybeArray 9 | 10 | export interface Filters { 11 | $sort?: { [prop: string]: number } 12 | $limit?: MaybeRef 13 | $skip?: MaybeRef 14 | $select?: string[] 15 | } 16 | export interface Query extends Filters, AnyData {} 17 | 18 | export interface Paginated { 19 | total: number 20 | limit: number 21 | skip: number 22 | data: T[] 23 | fromSsr?: true 24 | } 25 | 26 | export interface QueryInfo { 27 | qid: string 28 | query: Query 29 | queryId: string 30 | queryParams: Query 31 | pageParams: { $limit: MaybeRef; $skip: MaybeRef | undefined } | undefined 32 | pageId: string | undefined 33 | isExpired: boolean 34 | } 35 | 36 | export interface QueryInfoExtended extends QueryInfo { 37 | ids: Id[] 38 | items: ServiceInstance[] 39 | total: number 40 | queriedAt: number 41 | queryState: PaginationStateQuery 42 | } 43 | export type ExtendedQueryInfo = QueryInfoExtended | null 44 | 45 | export type DiffDefinition = undefined | string | string[] | Record | false 46 | 47 | export interface PaginationOptions { 48 | default?: number | true 49 | max?: number 50 | } 51 | 52 | export interface Params extends FeathersParams { 53 | query?: Q 54 | paginate?: boolean | PaginationOptions 55 | provider?: string 56 | route?: Record 57 | headers?: Record 58 | temps?: boolean 59 | clones?: boolean 60 | qid?: string 61 | ssr?: boolean 62 | skipGetIfExists?: boolean 63 | data?: any 64 | preserveSsr?: boolean 65 | } 66 | export interface PatchParams extends Params { 67 | /** 68 | * For `create` and `patch`, only. Provide `params.data` to specify exactly which data should be passed to the API 69 | * server. This will disable the built-in diffing that normally happens before `patch` requests. 70 | */ 71 | data?: Partial 72 | /** 73 | * For `patch` with clones, only. When you call patch (or save) on a clone, the data will be diffed before sending 74 | * it to the API server. If no data has changed, the request will be resolve without making a request. The `diff` 75 | * param lets you control which data gets diffed: 76 | * 77 | * - `diff: string` will only include the prop matching the provided string. 78 | * - `diff: string[]` will only include the props matched in the provided array of strings 79 | * - `diff: object` will compare the provided `diff` object with the original. 80 | */ 81 | diff?: DiffDefinition 82 | /** 83 | * For `patch` with clones, only. When you call patch (or save) on a clone, after the data is diffed, any data matching the `with` 84 | * param will also be included in the request. 85 | * 86 | * - `with: string` will include the prop matchin the provided string. 87 | * - `with: string[]` will include the props that match any string in the provided array. 88 | * - `with: object` will include the exact object along with the request. 89 | */ 90 | with?: DiffDefinition 91 | /** 92 | * For `patch` with clones, only. Set `params.eager` to false to prevent eager updates during patch requests. This behavior is enabled on patch 93 | * requests, by default. 94 | */ 95 | eager?: boolean 96 | } 97 | 98 | // for cloning 99 | export interface MakeCopyOptions { 100 | isClone: boolean 101 | } 102 | 103 | export type ById = Record 104 | -------------------------------------------------------------------------------- /feathers-pinia-3/use-find-get/use-get.ts: -------------------------------------------------------------------------------- 1 | import type { Id } from '@feathersjs/feathers' 2 | import type { ComputedRef } from 'vue-demi' 3 | import type { AnyData } from '../types' 4 | import type { UseFindGetDeps, UseGetParams } from './types' 5 | import type { MaybeRef } from '@vueuse/core' 6 | import { computed, ref, unref, watch, isRef } from 'vue-demi' 7 | 8 | type MaybeComputed = ComputedRef | MaybeRef 9 | 10 | export const useGet = ( 11 | _id: MaybeComputed, 12 | _params: MaybeRef = ref({}), 13 | deps: UseFindGetDeps, 14 | ) => { 15 | const { service } = deps 16 | 17 | // normalize args into refs 18 | const id = isRef(_id) ? _id : ref(_id) 19 | const params = isRef(_params) ? _params : ref(_params) 20 | 21 | /** ID & PARAMS **/ 22 | const { immediate = true, watch: _watch = true } = params.value 23 | const isSsr = computed(() => service.store.isSsr) 24 | 25 | /** REQUEST STATE **/ 26 | const isPending = ref(false) 27 | const hasBeenRequested = ref(false) 28 | const error = ref(null) 29 | const clearError = () => (error.value = null) 30 | 31 | /** STORE ITEMS **/ 32 | const ids = ref([]) 33 | const mostRecentId = computed(() => { 34 | return ids.value.length && ids.value[ids.value.length - 1] 35 | }) 36 | const data = computed(() => { 37 | if (isPending.value && mostRecentId.value != null) { 38 | const result = service.store.getFromStore(mostRecentId.value, params).value 39 | return result 40 | } 41 | const result = service.store.getFromStore(id.value, params).value 42 | return result 43 | }) 44 | const getFromStore = service.store.getFromStore 45 | 46 | const hasLoaded = computed(() => !!data.value) 47 | 48 | /** QUERY WHEN **/ 49 | let queryWhenFn = () => true 50 | const queryWhen = (_queryWhenFn: () => boolean) => { 51 | queryWhenFn = _queryWhenFn 52 | } 53 | 54 | /** SERVER FETCHING **/ 55 | const requestCount = ref(0) 56 | const request = ref | null>(null) 57 | async function get() { 58 | const _id = unref(id) 59 | const _params = unref(params) 60 | 61 | if (!queryWhenFn()) return 62 | 63 | if (_id == null) return null 64 | 65 | requestCount.value++ 66 | hasBeenRequested.value = true // never resets 67 | isPending.value = true 68 | error.value = null 69 | 70 | try { 71 | const response = await service.get(_id, _params) 72 | 73 | // Keep a list of retrieved ids 74 | if (response && _id) ids.value.push(_id) 75 | 76 | return response 77 | } catch (err: any) { 78 | error.value = err 79 | } finally { 80 | isPending.value = false 81 | } 82 | } 83 | 84 | async function makeRequest() { 85 | request.value = get() 86 | const val = await request.value 87 | return val 88 | } 89 | 90 | // Watch the id 91 | if (_watch) 92 | watch( 93 | id, 94 | async () => { 95 | await makeRequest() 96 | }, 97 | { immediate }, 98 | ) 99 | 100 | return { 101 | params, // Ref 102 | isSsr, // ComputedRef 103 | 104 | // Data 105 | data, // ComputedRef 106 | ids, // Ref 107 | getFromStore, // (id: Id | null, params: Params) => M | undefined 108 | 109 | // Requests & Watching 110 | get: makeRequest, // GetFn 111 | request, // Ref> 112 | requestCount, // Ref 113 | queryWhen, // (queryWhenFn: () => boolean) => void 114 | 115 | // Request State 116 | isPending: computed(() => isPending.value), // ComputedRef 117 | hasBeenRequested: computed(() => hasBeenRequested.value), // ComputedRef 118 | hasLoaded: computed(() => hasLoaded.value), // ComputedRef 119 | error: computed(() => error.value), // ComputedRef 120 | clearError, // () => void 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/storage.ts: -------------------------------------------------------------------------------- 1 | import type { Id } from '@feathersjs/feathers' 2 | import type { ById, AnyData } from '../types' 3 | import type { AssignFn, beforeWriteFn, onReadFn } from './types' 4 | import { computed, reactive, del as vueDel, set as vueSet } from 'vue-demi' 5 | 6 | interface UseServiceStorageOptions { 7 | getId: (item: M) => string 8 | onRead?: onReadFn 9 | beforeWrite?: beforeWriteFn 10 | assign?: AssignFn 11 | } 12 | 13 | export type StorageMapUtils = ReturnType> 14 | 15 | /** 16 | * General storage adapter 17 | */ 18 | export const useServiceStorage = ({ 19 | getId, 20 | onRead = (item) => item, 21 | beforeWrite = (item) => item, 22 | assign = (dest, src) => Object.assign(dest, src), 23 | }: UseServiceStorageOptions) => { 24 | const byId: ById = reactive({}) 25 | 26 | const list = computed(() => { 27 | return Object.values(byId) 28 | }) 29 | 30 | const ids = computed(() => { 31 | return Object.keys(byId) 32 | }) 33 | 34 | /** 35 | * Checks if the provided `item` is stored. 36 | * @param item 37 | * @returns boolean 38 | */ 39 | const has = (item: M) => { 40 | const id = getId(item) 41 | return hasItem(id as Id) 42 | } 43 | 44 | /** 45 | * Checks if an item with the provided `id` is stored. 46 | * @param id 47 | * @returns 48 | */ 49 | const hasItem = (id: Id) => { 50 | return !!byId[id] 51 | } 52 | 53 | /** 54 | * If the item is stored, merges the `item` into the stored version. 55 | * If not yet stored, the item is stored. 56 | * @param item the item to merge or write to the store. 57 | * @returns the stored item 58 | */ 59 | const merge = (item: M) => { 60 | const id = getId(item) as Id 61 | const existing = getItem(id) 62 | if (existing) assign(existing, item) 63 | else setItem(id, item) 64 | 65 | return getItem(id) 66 | } 67 | 68 | /** 69 | * Retrieves the stored record that matches the provided `item`. 70 | * @param item 71 | * @returns 72 | */ 73 | const get = (item: M) => { 74 | const id = getId(item) as Id 75 | return getItem(id) 76 | } 77 | 78 | /** 79 | * Retrives the stored record that matches the provided `id`. 80 | * @param id 81 | * @returns 82 | */ 83 | const getItem = (id: Id) => { 84 | const inStore = byId[id] 85 | const _item = inStore ? onRead(inStore) : null 86 | return _item as M 87 | } 88 | 89 | /** 90 | * Writes the provided item to the store 91 | * @param item item to store 92 | * @returns 93 | */ 94 | const set = (item: M) => { 95 | const id = getId(item) as Id 96 | return setItem(id, item) 97 | } 98 | 99 | const setItem = (id: Id, item: M) => { 100 | if (id == null) throw new Error('item has no id') 101 | vueSet(byId, id, beforeWrite(item)) 102 | return getItem(id) 103 | } 104 | 105 | /** 106 | * remove `item` if found 107 | * @param item 108 | * @returns boolean indicating if item was removed 109 | */ 110 | const remove = (item: M) => { 111 | const id = getId(item) as Id 112 | return removeItem(id) 113 | } 114 | 115 | /** 116 | * Remove item with matching `id`, if found. 117 | * @param id 118 | * @returns boolean indicating if an item was removed 119 | */ 120 | const removeItem = (id: Id) => { 121 | const hadItem = hasItem(id) 122 | if (hadItem) vueDel(byId, id) 123 | 124 | return hadItem 125 | } 126 | 127 | const getKeys = () => { 128 | return ids.value 129 | } 130 | 131 | /** 132 | * empties the store 133 | */ 134 | const clear = () => { 135 | Object.keys(byId).forEach((id) => { 136 | vueDel(byId, id) 137 | }) 138 | } 139 | 140 | return { byId, list, ids, getId, clear, has, hasItem, get, getItem, set, setItem, remove, removeItem, getKeys, merge } 141 | } 142 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/events.ts: -------------------------------------------------------------------------------- 1 | import type { FeathersService } from '@feathersjs/feathers' 2 | import type { HandleEvents, HandledEvents } from './types' 3 | import type { AnyData } from '../types' 4 | import { del, ref, set } from 'vue-demi' 5 | import { convertData, getId, hasOwn } from '../utils' 6 | import _debounce from 'just-debounce' 7 | import type { PiniaService } from '../create-pinia-service' 8 | 9 | interface UseServiceStoreEventsOptions { 10 | service: PiniaService 11 | debounceEventsTime?: number 12 | debounceEventsGuarantee?: boolean 13 | handleEvents?: HandleEvents 14 | } 15 | 16 | export const useServiceEvents = (options: UseServiceStoreEventsOptions) => { 17 | if (!options.service || options.handleEvents === false) return 18 | 19 | const service = options.service 20 | 21 | const addOrUpdateById = ref({}) 22 | const removeItemsById = ref({}) 23 | 24 | const flushAddOrUpdateQueue = _debounce( 25 | async () => { 26 | const values = Object.values(addOrUpdateById.value) 27 | if (values.length === 0) return 28 | service.store.createInStore(values) 29 | addOrUpdateById.value = {} 30 | }, 31 | options.debounceEventsTime || 20, 32 | undefined, 33 | options.debounceEventsGuarantee, 34 | ) 35 | 36 | function enqueueAddOrUpdate(item: any) { 37 | const id = getId(item, service.store.idField) 38 | if (!id) return 39 | 40 | set(addOrUpdateById, id, item) 41 | 42 | if (hasOwn(removeItemsById.value, id)) del(removeItemsById, id) 43 | 44 | flushAddOrUpdateQueue() 45 | } 46 | 47 | const flushRemoveItemQueue = _debounce( 48 | () => { 49 | const values = Object.values(removeItemsById.value) 50 | if (values.length === 0) return 51 | service.store.removeFromStore(values) 52 | removeItemsById.value = {} 53 | }, 54 | options.debounceEventsTime || 20, 55 | undefined, 56 | options.debounceEventsGuarantee, 57 | ) 58 | 59 | function enqueueRemoval(item: any) { 60 | const id = getId(item, service.store.idField) 61 | if (!id) return 62 | 63 | set(removeItemsById, id, item) 64 | 65 | if (hasOwn(addOrUpdateById.value, id)) del(addOrUpdateById.value, id) 66 | 67 | flushRemoveItemQueue() 68 | } 69 | 70 | function handleEvent(eventName: HandledEvents, item: any) { 71 | const handler = (options.handleEvents as any)?.[eventName] 72 | if (handler === false) return 73 | 74 | /** 75 | * For `created` events, we don't know the id since it gets assigned on the server. Also, since `created` events 76 | * arrive before the `create` response, we only act on other events. For all other events, toggle the event lock. 77 | */ 78 | const id = getId(item, service.store.idField) 79 | if (eventName !== 'created' && service.store.eventLocks[eventName][id]) { 80 | service.store.toggleEventLock(id, eventName) 81 | return 82 | } 83 | 84 | if (handler) { 85 | const handled = handler(item, { service }) 86 | if (!handled) return 87 | } 88 | 89 | if (!options.debounceEventsTime) 90 | eventName === 'removed' ? service.store.removeFromStore(item) : service.store.createInStore(item) 91 | else eventName === 'removed' ? enqueueRemoval(item) : enqueueAddOrUpdate(item) 92 | } 93 | 94 | // Listen to socket events when available. 95 | service.on('created', (item: any) => { 96 | const data = convertData(service, item) 97 | handleEvent('created', data) 98 | }) 99 | service.on('updated', (item: any) => { 100 | const data = convertData(service, item) 101 | handleEvent('updated', data) 102 | }) 103 | service.on('patched', (item: any) => { 104 | const data = convertData(service, item) 105 | handleEvent('patched', data) 106 | }) 107 | service.on('removed', (item: any) => { 108 | const data = convertData(service, item) 109 | handleEvent('removed', data) 110 | }) 111 | } 112 | -------------------------------------------------------------------------------- /feathers-pinia-3/modeling/types.ts: -------------------------------------------------------------------------------- 1 | import type { AnyData, PatchParams } from '../types' 2 | import type { CloneOptions } from '../stores' 3 | 4 | export interface BaseModelData { 5 | /** 6 | * Indicates if this instance is a clone.. It will be 7 | * 8 | * - `true` after calling `instance.clone()` 9 | * - `false` after calling `instance.commit()`. 10 | * 11 | * Should not be set manually when creating new instances. 12 | */ 13 | __isClone?: boolean 14 | /** 15 | * If no idField is specified, `__tempId` can be manually specified, otherwise a random one will be added for you. 16 | * The automatically-added `__tempId` values are valid ObjectId strings. 17 | */ 18 | __tempId?: string 19 | } 20 | 21 | export interface StoreInstanceProps { 22 | /** 23 | * The name of the Model function 24 | */ 25 | readonly __modelName: string 26 | 27 | // see `BaseModelData.__isClone` 28 | readonly __isClone: boolean 29 | /** 30 | * The attribute on the data holding the unique id property. It will match whatever was provided in the Model options. 31 | * This value should match the API service. General `idField` values are as follows: 32 | * 33 | * - `id` for SQL databases 34 | * - `_id` for MongoDB 35 | */ 36 | readonly __idField: string 37 | 38 | // see `BaseModelData.__tempId` 39 | readonly __tempId: string 40 | /** 41 | * A boolean indicating if the instance is a temp. Will be `true` if the instance does not have an idField. This is 42 | * the only reliable way to determine if a record is a temp or not, since after calling `temp.save`, the temp will 43 | * have both a `__tempId` and a real idField value. 44 | */ 45 | readonly __isTemp: boolean 46 | /** 47 | * Returns the item's clone from the store, if one exists. 48 | */ 49 | hasClone(this: N): N | null 50 | /** 51 | * Creates a copy of an item or temp record. The copy will have `__isClone` set to `true` and will be added to the 52 | * Model's clone storage. If not already stored, the original item will be added to the appropriate store. 53 | * @param data 54 | * @param options 55 | */ 56 | clone(this: N, data?: Partial, options?: CloneOptions): N 57 | /** 58 | * Copies a clone's data onto the original item or temp record. 59 | * @param data 60 | * @param options 61 | */ 62 | commit(this: N, data?: Partial, options?: CloneOptions): N 63 | /** 64 | * Resets a clone's data to match the original item or temp record. If additional properties were added to the clone, 65 | * they will be removed to exactly match the original. 66 | * @param data 67 | * @param options 68 | */ 69 | reset(this: N, data?: Partial, options?: CloneOptions): N 70 | /** 71 | * Adds the current instance to the appropriate store. If the instance is a clone, it will be added to `clones`. If it 72 | * has an `idField`, it will be added to items, otherwise it will be added to temps. 73 | */ 74 | createInStore(this: N): N 75 | /** 76 | * Removes the current instance from items, temps, and clones. 77 | */ 78 | removeFromStore(this: N): N 79 | } 80 | 81 | export type ModelInstanceData = Partial 82 | export type ModelInstance = ModelInstanceData & StoreInstanceProps 83 | 84 | export interface ServiceInstanceProps = PatchParams> { 85 | readonly isSavePending: boolean 86 | readonly isCreatePending: boolean 87 | readonly isPatchPending: boolean 88 | readonly isRemovePending: boolean 89 | save: (this: N, params?: P) => Promise 90 | create: (this: ModelInstance, params?: P) => Promise 91 | patch: (this: ModelInstance, params?: P) => Promise 92 | remove: (this: ModelInstance, params?: P) => Promise 93 | } 94 | export type ServiceInstance = ModelInstanceData & 95 | StoreInstanceProps & 96 | ServiceInstanceProps 97 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/pagination.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef, Ref } from 'vue-demi' 2 | import type { PaginationState, UpdatePaginationForQueryOptions } from './types' 3 | import { ref, set } from 'vue-demi' 4 | import { deepUnref, getId, hasOwn } from '../utils' 5 | import stringify from 'fast-json-stable-stringify' 6 | import { _ } from '@feathersjs/commons/lib' 7 | import { Params, Query, QueryInfo } from '../types' 8 | 9 | export interface UseServicePagination { 10 | idField: string 11 | isSsr: ComputedRef 12 | defaultLimit?: number 13 | } 14 | 15 | export const useServicePagination = (options: UseServicePagination) => { 16 | const { idField, isSsr } = options 17 | const defaultLimit = options.defaultLimit || 10 18 | 19 | const pagination = ref({}) as Ref 20 | 21 | function clearPagination() { 22 | const { defaultLimit, defaultSkip } = pagination.value 23 | pagination.value = { defaultLimit, defaultSkip } as any 24 | } 25 | 26 | /** 27 | * Stores pagination data on state.pagination based on the query identifier 28 | * (qid) The qid must be manually assigned to `params.qid` 29 | */ 30 | function updatePaginationForQuery({ 31 | qid, 32 | response, 33 | query = {}, 34 | preserveSsr = false, 35 | }: UpdatePaginationForQueryOptions) { 36 | const { data, total } = response 37 | const ids = data.map((i: any) => getId(i, idField)) 38 | const queriedAt = new Date().getTime() 39 | const { queryId, queryParams, pageId, pageParams } = getQueryInfo({ qid, query }) 40 | 41 | if (!pagination.value[qid]) set(pagination.value, qid, {}) 42 | 43 | if (!hasOwn(query, '$limit') && hasOwn(response, 'limit')) set(pagination.value, 'defaultLimit', response.limit) 44 | 45 | if (!hasOwn(query, '$skip') && hasOwn(response, 'skip')) set(pagination.value, 'defaultSkip', response.skip) 46 | 47 | const mostRecent = { 48 | query, 49 | queryId, 50 | queryParams, 51 | pageId, 52 | pageParams, 53 | queriedAt, 54 | total, 55 | } 56 | 57 | const existingPageData = pagination.value[qid]?.[queryId]?.[pageId as string] 58 | 59 | const qidData = pagination.value[qid] || {} 60 | Object.assign(qidData, { mostRecent }) 61 | 62 | set(qidData, queryId, qidData[queryId] || {}) 63 | const queryData = { 64 | total, 65 | queryParams, 66 | } 67 | 68 | set(qidData, queryId, Object.assign({}, qidData[queryId], queryData)) 69 | 70 | const ssr = preserveSsr ? existingPageData?.ssr : isSsr.value 71 | 72 | const pageData = { 73 | [pageId as string]: { pageParams, ids, queriedAt, ssr: !!ssr }, 74 | } 75 | 76 | Object.assign(qidData[queryId], pageData) 77 | 78 | const newState = Object.assign({}, pagination.value[qid], qidData) 79 | 80 | set(pagination.value, qid, newState) 81 | } 82 | 83 | function unflagSsr(params: Params) { 84 | const queryInfo = getQueryInfo(params) 85 | const { qid, queryId, pageId } = queryInfo 86 | 87 | const pageData = pagination.value[qid]?.[queryId]?.[pageId as string] 88 | pageData.ssr = false 89 | } 90 | 91 | function getQueryInfo(_params: Params): QueryInfo { 92 | const params = deepUnref(_params) 93 | const { query = {} } = params 94 | const qid = params.qid || 'default' 95 | const $limit = query?.$limit || defaultLimit 96 | const $skip = query?.$skip || 0 97 | 98 | const pageParams = $limit !== undefined ? { $limit, $skip } : undefined 99 | const pageId = pageParams ? stringify(pageParams) : undefined 100 | 101 | const queryParams = _.omit(query, '$limit', '$skip') 102 | const queryId = stringify(queryParams) 103 | 104 | return { 105 | qid, 106 | query, 107 | queryId, 108 | queryParams, 109 | pageParams, 110 | pageId, 111 | isExpired: false, 112 | } 113 | } 114 | 115 | return { 116 | pagination, 117 | updatePaginationForQuery, 118 | unflagSsr, 119 | getQueryInfo, 120 | clearPagination, 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/use-service-store.ts: -------------------------------------------------------------------------------- 1 | import type { Query } from '@feathersjs/feathers' 2 | 3 | import { computed, unref } from 'vue-demi' 4 | import type { AnyData } from '../types' 5 | import { MaybeRef } from '@vueuse/core' 6 | import { useServiceLocal } from './local-queries' 7 | 8 | import { useServicePagination } from './pagination' 9 | import { useServicePending } from './pending' 10 | import { useServiceEventLocks } from './event-locks' 11 | import { useAllStorageTypes } from './all-storage-types' 12 | import { useModelInstance } from '../modeling/use-model-instance' 13 | 14 | export interface UseServiceStoreOptions { 15 | idField: string 16 | defaultLimit?: number 17 | whitelist?: string[] 18 | paramsForServer?: string[] 19 | skipGetIfExists?: boolean 20 | ssr?: MaybeRef 21 | customSiftOperators?: Record 22 | setupInstance?: any 23 | } 24 | 25 | const makeDefaultOptions = () => ({ 26 | skipGetIfExists: false, 27 | }) 28 | 29 | export const useServiceStore = (_options: UseServiceStoreOptions) => { 30 | const options = Object.assign({}, makeDefaultOptions(), _options) 31 | const { idField, whitelist, paramsForServer, defaultLimit, customSiftOperators } = options 32 | 33 | function setupInstance(this: any, data: N) { 34 | const asBaseModel = useModelInstance(data, { 35 | idField, 36 | clonesById: cloneStorage.byId, 37 | clone, 38 | commit, 39 | reset, 40 | createInStore, 41 | removeFromStore, 42 | }) 43 | 44 | if (data.__isSetup) return asBaseModel 45 | else { 46 | const afterSetup = options.setupInstance ? options.setupInstance(asBaseModel) : asBaseModel 47 | Object.defineProperty(afterSetup, '__isSetup', { value: true }) 48 | return afterSetup 49 | } 50 | } 51 | 52 | // pending state 53 | const pendingState = useServicePending() 54 | 55 | // storage 56 | const { itemStorage, tempStorage, cloneStorage, clone, commit, reset, addItemToStorage } = useAllStorageTypes({ 57 | getIdField: (val: AnyData) => val[idField], 58 | setupInstance, 59 | }) 60 | 61 | const isSsr = computed(() => { 62 | const ssr = unref(options.ssr) 63 | return !!ssr 64 | }) 65 | 66 | // pagination 67 | const { pagination, clearPagination, updatePaginationForQuery, getQueryInfo, unflagSsr } = useServicePagination({ 68 | idField, 69 | isSsr, 70 | defaultLimit, 71 | }) 72 | 73 | function clearAll() { 74 | itemStorage.clear() 75 | tempStorage.clear() 76 | cloneStorage.clear() 77 | clearPagination() 78 | pendingState.clearAllPending() 79 | } 80 | 81 | // local data filtering 82 | const { findInStore, findOneInStore, countInStore, getFromStore, createInStore, patchInStore, removeFromStore } = 83 | useServiceLocal({ 84 | idField, 85 | itemStorage, 86 | tempStorage, 87 | cloneStorage, 88 | addItemToStorage, 89 | whitelist, 90 | paramsForServer, 91 | customSiftOperators, 92 | }) 93 | 94 | // event locks 95 | const eventLocks = useServiceEventLocks() 96 | 97 | const store = { 98 | new: setupInstance, 99 | idField, 100 | isSsr, 101 | defaultLimit, 102 | 103 | // items 104 | itemsById: itemStorage.byId, 105 | items: itemStorage.list, 106 | itemIds: itemStorage.ids, 107 | 108 | // temps 109 | tempsById: tempStorage.byId, 110 | temps: tempStorage.list, 111 | tempIds: tempStorage.ids, 112 | 113 | // clones 114 | clonesById: cloneStorage.byId, 115 | clones: cloneStorage.list, 116 | cloneIds: cloneStorage.ids, 117 | clone, 118 | commit, 119 | reset, 120 | 121 | // local queries 122 | findInStore, 123 | findOneInStore, 124 | countInStore, 125 | createInStore, 126 | getFromStore, 127 | patchInStore, 128 | removeFromStore, 129 | clearAll, 130 | 131 | // server options 132 | whitelist, 133 | paramsForServer, 134 | 135 | // server pagination 136 | pagination, 137 | updatePaginationForQuery, 138 | unflagSsr, 139 | getQueryInfo, 140 | ...pendingState, 141 | ...eventLocks, 142 | } 143 | 144 | return store 145 | } 146 | -------------------------------------------------------------------------------- /components/Contacts/Form/ContactsForm.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 132 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/clones.ts: -------------------------------------------------------------------------------- 1 | import type { AnyData, MakeCopyOptions } from '../types' 2 | import type { CloneOptions, beforeWriteFn, onReadFn } from './types' 3 | import type { StorageMapUtils } from './storage' 4 | import { useServiceStorage } from './storage' 5 | import { del as vueDelete } from 'vue-demi' 6 | import fastCopy from 'fast-copy' 7 | 8 | export interface UseServiceClonesOptions { 9 | itemStorage: StorageMapUtils 10 | tempStorage: StorageMapUtils 11 | onRead?: onReadFn 12 | beforeWrite?: beforeWriteFn 13 | makeCopy?: (item: M, data: AnyData, { isClone }: MakeCopyOptions) => M 14 | } 15 | 16 | export const useServiceClones = (options: UseServiceClonesOptions) => { 17 | const { itemStorage, tempStorage, onRead, beforeWrite } = options 18 | const defaultMakeCopy = (item: M, data: AnyData = {}, { isClone }: MakeCopyOptions) => { 19 | return fastCopy(Object.assign({}, item, data, { __isClone: isClone })) 20 | } 21 | const makeCopy = options.makeCopy || defaultMakeCopy 22 | 23 | const cloneStorage = useServiceStorage({ 24 | getId: (item) => { 25 | const id = itemStorage.getId(item as M) 26 | return id != null ? id : tempStorage.getId(item) 27 | }, 28 | onRead, 29 | beforeWrite, 30 | }) 31 | 32 | /** 33 | * Makes sure the provided item is stored in itemStorage or tempStorage. 34 | * Private 35 | */ 36 | function assureOriginalIsStored(item: M): M { 37 | // Make sure the stored version is always up to date with the latest instance data. (the instance used to call instance.clone) 38 | if (!item.__isClone) { 39 | if (itemStorage.has(item)) itemStorage.merge(item) 40 | else if (tempStorage.has(item)) tempStorage.merge(item) 41 | } 42 | const existingItem = itemStorage.get(item) || tempStorage.get(item) 43 | if (!existingItem) { 44 | if (itemStorage.getId(item) != null) itemStorage.merge(item) 45 | else if (tempStorage.getId(item) != null) tempStorage.merge(item) 46 | } 47 | return itemStorage.get(item) || tempStorage.get(item) 48 | } 49 | 50 | /** 51 | * Fast-copies the provided `item`, placing it in `cloneStorage`. 52 | * @param item the object to clone. 53 | * @param data an object to be merged before storing in cloneStorage. 54 | * @param options.useExisting {Boolean} allows using the existing clone instead of re-cloning. 55 | * @returns 56 | */ 57 | function clone(item: M, data = {}, options: CloneOptions = {}): M { 58 | const existingClone = cloneStorage.get(item) 59 | 60 | assureOriginalIsStored(item) 61 | 62 | if (existingClone && options.useExisting) { 63 | return existingClone as M 64 | } else { 65 | const clone = reset(item, data) 66 | return clone as M 67 | } 68 | } 69 | 70 | /** 71 | * If the `item` has an id, it's merged or written to the itemStore. 72 | * If the `item` does not have an id, it's merged or written to the tempStore. 73 | * @param item 74 | * @param data 75 | * @returns stored item or stored temp 76 | */ 77 | function commit(item: M, data: Partial = {}) { 78 | const itemId = itemStorage.getId(item) 79 | const _item = makeCopy(item, data, { isClone: false }) 80 | // copyAssociations(clone, newOriginal, clone.getModel().associations) 81 | if (itemId) { 82 | itemStorage.merge(_item) 83 | return itemStorage.get(_item) 84 | } else { 85 | tempStorage.merge(_item) 86 | return tempStorage.get(_item) 87 | } 88 | } 89 | 90 | /** 91 | * If a clone exists, resets the clone to match the item or temp 92 | * If a clone does not exist, writes the item as the clone. 93 | * @param item 94 | * @param data 95 | * @returns 96 | */ 97 | function reset(item: M, data = {}): M { 98 | const original = assureOriginalIsStored(item) 99 | const existingClone = cloneStorage.get(item) 100 | 101 | if (existingClone) { 102 | const copied = makeCopy(original, data, { isClone: true }) 103 | Object.keys(original).forEach((key) => { 104 | if (original[key] == null) vueDelete(copied, key) 105 | }) 106 | cloneStorage.merge(copied) 107 | } else { 108 | const copied = makeCopy(item, data, { isClone: true }) 109 | cloneStorage.set(copied) 110 | } 111 | return cloneStorage.get(item) 112 | } 113 | 114 | function markAsClone(item: M) { 115 | Object.defineProperty(item, '__isClone', { 116 | writable: false, 117 | enumerable: false, 118 | value: true, 119 | }) 120 | return item 121 | } 122 | 123 | return { 124 | cloneStorage, 125 | clone, 126 | commit, 127 | reset, 128 | markAsClone, 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /feathers-pinia-3/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeRef } from '@vueuse/core' 2 | import type { Ref } from 'vue-demi' 3 | import { _ } from '@feathersjs/commons' 4 | import { unref } from 'vue-demi' 5 | import isEqual from 'fast-deep-equal' 6 | import fastCopy from 'fast-copy' 7 | import type { AnyData, AnyDataOrArray, DiffDefinition, Params, Query, QueryInfo } from '../types' 8 | import { defineValues } from './define-properties' 9 | import { convertData } from './convert-data' 10 | 11 | interface GetExtendedQueryInfoOptions { 12 | queryInfo: QueryInfo 13 | service: any 14 | store: any 15 | qid: Ref 16 | } 17 | export function getExtendedQueryInfo({ queryInfo, service, store, qid }: GetExtendedQueryInfoOptions) { 18 | const qidState: any = store.pagination[qid.value] 19 | const queryState = qidState[queryInfo.queryId] 20 | if (!queryState) 21 | return null 22 | 23 | const { total } = queryState 24 | const pageState = queryState[queryInfo.pageId as string] 25 | if (!pageState) 26 | return null 27 | 28 | const { ids, queriedAt, ssr } = pageState 29 | const result = ids.map((id: any) => store.itemsById[id]).filter((i: any) => i) 30 | const items = convertData(service, result) 31 | const info = { ...queryInfo, ids, items, total, queriedAt, queryState, ssr } 32 | return info || null 33 | } 34 | 35 | export function hasOwn(obj: AnyData, prop: string) { 36 | return Object.prototype.hasOwnProperty.call(obj, prop) 37 | } 38 | 39 | /** 40 | * 41 | * @param data item or array of items 42 | * @returns object with { items[], isArray } where isArray is a boolean of if the data was an array. 43 | */ 44 | export function getArray(data: T | T[]) { 45 | const isArray = Array.isArray(data) 46 | return { items: isArray ? data : [data], isArray } 47 | } 48 | 49 | export function pickDiff(obj: any, diffDef: DiffDefinition) { 50 | // If no diff definition was given, return the entire object. 51 | if (!diffDef) 52 | return obj 53 | 54 | // Normalize all types into an array and pick the keys 55 | const keys = typeof diffDef === 'string' ? [diffDef] : Array.isArray(diffDef) ? diffDef : Object.keys(diffDef || obj) 56 | const topLevelKeys = keys.map(key => key.toString().split('.')[0]) 57 | return _.pick(obj, ...topLevelKeys) 58 | } 59 | 60 | export function diff(original: AnyData, clone: AnyData, diffDef: DiffDefinition) { 61 | const originalVal = pickDiff(original, diffDef) 62 | const cloneVal = pickDiff(clone, diffDef) 63 | 64 | // If diff was an object, merge the values into the cloneVal 65 | if (typeof diffDef !== 'string' && !Array.isArray(diffDef)) 66 | Object.assign(cloneVal, diffDef) 67 | 68 | const areEqual = isEqual(originalVal, cloneVal) 69 | 70 | if (areEqual) 71 | return {} 72 | 73 | // Loop through clone, compare original value to clone value, if different add to diff object. 74 | const diff = Object.keys(cloneVal).reduce((diff: AnyData, key) => { 75 | if (!isEqual(original[key], cloneVal[key])) 76 | diff[key] = cloneVal[key] 77 | 78 | return diff 79 | }, {}) 80 | 81 | return diff 82 | } 83 | 84 | /** 85 | * Restores tempIds to the records returned from the server. The tempIds need to be 86 | * temporarily put back in place in order to migrate the objects from the tempsById 87 | * into the itemsById. A shallow copy of the object 88 | * 89 | * Note when data is an array, it doesn't matter if the server 90 | * returns the items in the same order. It's only important that all of the correct 91 | * records are moved from tempsById to itemsById 92 | * 93 | * @param data item(s) before being passed to the server 94 | * @param responseData items(s) returned from the server 95 | */ 96 | export function restoreTempIds(data: AnyDataOrArray, resData: AnyDataOrArray, tempIdField = '__tempId') { 97 | const { items: sourceItems, isArray } = getArray(data) 98 | const { items: responseItems } = getArray(resData) 99 | 100 | responseItems.forEach((item: any, index: number) => { 101 | const tempId = sourceItems[index][tempIdField] 102 | if (tempId) 103 | defineValues(item, { [tempIdField]: tempId }) 104 | }) 105 | 106 | return isArray ? responseItems : responseItems[0] 107 | } 108 | 109 | function stringifyIfObject(val: any): string | any { 110 | if (typeof val === 'object' && val != null) 111 | return val.toString() 112 | 113 | return val 114 | } 115 | 116 | /** 117 | * Get the id from a record in this order: 118 | * 1. the `idField` 119 | * 2. id 120 | * 3. _id 121 | * @param item 122 | * @param idField 123 | */ 124 | export function getId(item: any, idField: string) { 125 | if (!item) 126 | return 127 | if (idField && item[idField] !== undefined) 128 | return stringifyIfObject(item[idField as string]) 129 | 130 | if (item.id !== undefined) 131 | return stringifyIfObject(item.id) 132 | 133 | if (item._id !== undefined) 134 | return stringifyIfObject(item._id) 135 | } 136 | 137 | /** 138 | * Assures params exist. 139 | * @param params existing params 140 | */ 141 | export function getParams(params?: MaybeRef>): Params { 142 | if (!params) 143 | return {} 144 | 145 | return fastCopy(unref(params)) 146 | } 147 | 148 | export function timeout(ms: number) { 149 | return new Promise(resolve => setTimeout(resolve, ms)) 150 | } 151 | -------------------------------------------------------------------------------- /feathers-pinia-3/create-pinia-client.ts: -------------------------------------------------------------------------------- 1 | import type { Application, FeathersService } from '@feathersjs/feathers' 2 | import type { HandleEvents } from './stores' 3 | import type { AnyData } from './types' 4 | import { feathers } from '@feathersjs/feathers' 5 | import { defineStore } from 'pinia' 6 | import { PiniaService } from './create-pinia-service' 7 | import { useServiceStore, useServiceEvents } from './stores' 8 | import { feathersPiniaHooks } from './hooks' 9 | import { storeAssociated, useServiceInstance } from './modeling' 10 | import { defineGetters } from './utils' 11 | 12 | interface SetupInstanceUtils { 13 | app?: any 14 | service?: any 15 | servicePath?: string 16 | } 17 | 18 | interface PiniaServiceConfig { 19 | idField?: string 20 | defaultLimit?: number 21 | whitelist?: string[] 22 | paramsForServer?: string[] 23 | skipGetIfExists?: boolean 24 | handleEvents?: HandleEvents 25 | debounceEventsTime?: number 26 | debounceEventsGuarantee?: boolean 27 | setupInstance?: (data: any, utils: SetupInstanceUtils) => any 28 | customizeStore?: (data: ReturnType) => Record 29 | customSiftOperators?: Record 30 | } 31 | 32 | interface CreatePiniaClientConfig extends PiniaServiceConfig { 33 | idField: string 34 | pinia: any 35 | ssr?: boolean 36 | services?: Record 37 | } 38 | 39 | type CreatePiniaServiceTypes = { 40 | [Key in keyof T]: PiniaService 41 | } 42 | 43 | interface AppExtensions { 44 | storeAssociated: (data: any, config: Record) => void 45 | } 46 | 47 | export function createPiniaClient( 48 | client: Client, 49 | options: CreatePiniaClientConfig, 50 | ): Application> & AppExtensions { 51 | const vueApp = feathers() 52 | 53 | vueApp.defaultService = function (location: string) { 54 | const serviceOptions = options.services?.[location] || {} 55 | 56 | // combine service and global options 57 | const idField = serviceOptions.idField || options.idField 58 | const defaultLimit = serviceOptions.defaultLimit || options.defaultLimit || 10 59 | const whitelist = (serviceOptions.whitelist || []).concat(options.whitelist || []) 60 | const paramsForServer = (serviceOptions.paramsForServer || []).concat(options.paramsForServer || []) 61 | const handleEvents = serviceOptions.handleEvents || options.handleEvents 62 | const debounceEventsTime = 63 | serviceOptions.debounceEventsTime != null ? serviceOptions.debounceEventsTime : options.debounceEventsTime 64 | const debounceEventsGuarantee = 65 | serviceOptions.debounceEventsGuarantee != null 66 | ? serviceOptions.debounceEventsGuarantee 67 | : options.debounceEventsGuarantee 68 | const customSiftOperators = Object.assign( 69 | {}, 70 | serviceOptions.customSiftOperators || {}, 71 | options.customSiftOperators || {}, 72 | ) 73 | function customizeStore(utils: any) { 74 | const fromGlobal = Object.assign(utils, options.customizeStore ? options.customizeStore(utils) : utils) 75 | const fromService = Object.assign( 76 | fromGlobal, 77 | serviceOptions.customizeStore ? serviceOptions.customizeStore(fromGlobal) : fromGlobal, 78 | ) 79 | return fromService 80 | } 81 | 82 | function wrappedSetupInstance(data: any) { 83 | const asFeathersModel = useServiceInstance(data, { 84 | service: vueApp.service(location), 85 | store, 86 | }) 87 | 88 | // call the provided `setupInstance` 89 | const utils = { app: vueApp, service: vueApp.service(location), servicePath: location } 90 | const fromGlobal = options.setupInstance ? options.setupInstance(asFeathersModel, utils) : asFeathersModel 91 | const serviceLevel = serviceOptions.setupInstance ? serviceOptions.setupInstance(data, utils) : fromGlobal 92 | return serviceLevel 93 | } 94 | 95 | // create pinia store 96 | const storeName = `service:${location}` 97 | const useStore = defineStore(storeName, () => { 98 | const utils = useServiceStore({ 99 | idField, 100 | defaultLimit, 101 | whitelist, 102 | paramsForServer, 103 | customSiftOperators, 104 | ssr: options.ssr, 105 | setupInstance: wrappedSetupInstance, 106 | }) 107 | const custom = customizeStore(utils) 108 | return { ...utils, ...custom } 109 | }) 110 | const store = useStore(options.pinia) 111 | 112 | const clientService = client.service(location) 113 | const piniaService = new PiniaService(clientService, { store, servicePath: location }) 114 | 115 | useServiceEvents({ 116 | service: piniaService, 117 | debounceEventsTime, 118 | debounceEventsGuarantee, 119 | handleEvents, 120 | }) 121 | 122 | return piniaService 123 | } 124 | 125 | // register hooks on every service 126 | const mixin: any = (service: any) => { 127 | service.hooks({ 128 | around: feathersPiniaHooks(), 129 | }) 130 | } 131 | vueApp.mixins.push(mixin) 132 | 133 | defineGetters(vueApp, { 134 | authentication() { 135 | return (client as any).authentication 136 | }, 137 | authenticate() { 138 | return (client as any).authenticate 139 | }, 140 | reAuthenticate() { 141 | return (client as any).reAuthenticate 142 | }, 143 | logout() { 144 | return (client as any).logout 145 | }, 146 | }) 147 | 148 | Object.assign(vueApp, { storeAssociated }) 149 | 150 | return vueApp as Application> & AppExtensions 151 | } 152 | -------------------------------------------------------------------------------- /feathers-pinia-3/use-auth/use-auth.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue-demi' 2 | import type { NullableId } from '@feathersjs/feathers' 3 | import { computed, ref } from 'vue-demi' 4 | import { BadRequest } from '@feathersjs/errors' 5 | import decode from 'jwt-decode' 6 | import { useCounter } from '../utils/use-counter' 7 | 8 | type SuccessHandler = (result: Record) => Promise | void> 9 | type ErrorHandler = (error: Error) => Promise 10 | 11 | interface UseAuthOptions { 12 | api: any 13 | servicePath?: string 14 | skipTokenCheck?: boolean 15 | entityKey?: string 16 | onSuccess?: SuccessHandler 17 | onError?: ErrorHandler 18 | onInitSuccess?: SuccessHandler 19 | onInitError?: ErrorHandler 20 | onLogoutSuccess?: SuccessHandler 21 | onLogoutError?: ErrorHandler 22 | } 23 | 24 | interface AuthenticateData { 25 | strategy: 'jwt' | 'local' 26 | accessToken?: string 27 | email?: string 28 | password?: string 29 | } 30 | 31 | export function useAuth(options: UseAuthOptions) { 32 | const { api, servicePath, skipTokenCheck } = options 33 | const entityService = servicePath ? api.service(servicePath) : null 34 | const entityKey = options.entityKey || 'user' 35 | 36 | // external flow 37 | const promise = ref() as Ref>> 38 | const defaultHandler = async () => undefined 39 | const defaultErrorHandler = async (error: any) => { 40 | throw error 41 | } 42 | const onSuccess: SuccessHandler = options.onSuccess || defaultHandler 43 | const onError: ErrorHandler = options.onError || defaultErrorHandler 44 | const onInitSuccess: SuccessHandler = options.onInitSuccess || defaultHandler 45 | const onInitError: ErrorHandler = options.onInitError || defaultHandler 46 | const onLogoutSuccess: SuccessHandler = options.onLogoutSuccess || defaultHandler 47 | const onLogoutError: ErrorHandler = options.onLogoutError || defaultErrorHandler 48 | 49 | // user 50 | const userId = ref(null) 51 | const user = computed(() => { 52 | if (!entityService) return null 53 | const u = entityService?.getFromStore(userId) 54 | return u.value || null 55 | }) 56 | 57 | // error 58 | const error = ref(null) 59 | const clearError = () => (error.value = null) 60 | 61 | // authenticate 62 | const authCounter = useCounter() 63 | const isPending = computed(() => !!authCounter.count.value) 64 | const isAuthenticated = ref(false) 65 | const handleAuthResult = (result: any) => { 66 | const entity = result[entityKey] 67 | if (entityService && entity) { 68 | const stored = entityService.store.createInStore(entity) 69 | userId.value = stored[entityService.store.idField] || stored.__tempId 70 | } 71 | isAuthenticated.value = true 72 | return result 73 | } 74 | const authenticate = async (data?: d) => { 75 | authCounter.add() 76 | clearError() 77 | promise.value = api 78 | .authenticate(data) 79 | .then(handleAuthResult) 80 | .then(async (result: Record) => { 81 | const _result = await onSuccess(result) 82 | return _result || result 83 | }) 84 | .catch((err: any) => { 85 | error.value = err 86 | return onError(err) 87 | }) 88 | .finally(() => { 89 | authCounter.sub() 90 | }) 91 | return promise.value 92 | } 93 | 94 | // token check 95 | const isTokenExpired = (jwt: string) => { 96 | try { 97 | const payload = decode(jwt) as any 98 | return new Date().getTime() > payload.exp * 1000 99 | } catch (error) { 100 | return false 101 | } 102 | } 103 | 104 | // reauthentication at app start 105 | const isInitDone = ref(false) 106 | const reAuthenticate = async () => { 107 | authCounter.add() 108 | promise.value = api.authentication 109 | .getAccessToken() 110 | .then((accessToken: string) => { 111 | if (accessToken && !skipTokenCheck && isTokenExpired(accessToken)) { 112 | api.authentication.removeAccessToken() 113 | throw new BadRequest('accessToken expired') 114 | } 115 | }) 116 | .then(() => api.reAuthenticate()) 117 | .then(handleAuthResult) 118 | .then(async (result: Record) => { 119 | const _result = await onInitSuccess(result) 120 | return _result || result 121 | }) 122 | .catch((error: any) => { 123 | error.value = error 124 | return onInitError(error) 125 | }) 126 | .finally(() => { 127 | authCounter.sub() 128 | isInitDone.value = true 129 | }) 130 | return promise.value 131 | } 132 | 133 | // logout 134 | const logoutCounter = useCounter() 135 | const isLogoutPending = computed(() => !!logoutCounter.count.value) 136 | const logout = async () => { 137 | logoutCounter.add() 138 | return api 139 | .logout() 140 | .then((response: any) => { 141 | userId.value = null 142 | isAuthenticated.value = false 143 | return response 144 | }) 145 | .then(onLogoutSuccess) 146 | .catch((error: any) => { 147 | error.value = error 148 | return onLogoutError(error) 149 | }) 150 | .finally(() => logoutCounter.sub()) 151 | } 152 | 153 | // login redirect 154 | const loginRedirect = ref | null>(null) 155 | 156 | return { 157 | user, 158 | error, 159 | isPending, 160 | isLogoutPending, 161 | isInitDone, 162 | isAuthenticated, 163 | loginRedirect, 164 | getPromise: () => promise.value, 165 | isTokenExpired, 166 | authenticate, 167 | reAuthenticate, 168 | logout, 169 | clearError, 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /feathers-pinia-3/create-pinia-service.ts: -------------------------------------------------------------------------------- 1 | import type { Params as FeathersParams, FeathersService, Id } from '@feathersjs/feathers' 2 | import type { AnyData, Params, Query } from './types' 3 | import type { MaybeRef } from '@vueuse/core' 4 | import type { UseFindOptions, UseFindParams, UseGetParams } from './use-find-get' 5 | import type { ComputedRef } from 'vue-demi' 6 | import { reactive, computed, isRef, ref, unref } from 'vue-demi' 7 | import { getParams } from './utils' 8 | import { useFind, useGet } from './use-find-get' 9 | import { convertData } from './utils/convert-data' 10 | import { ServiceInstance } from './modeling' 11 | 12 | interface PiniaServiceOptions { 13 | servicePath: string 14 | store: any 15 | } 16 | 17 | export class PiniaService { 18 | store 19 | servicePath = '' 20 | 21 | constructor(public service: Svc, public options: PiniaServiceOptions) { 22 | this.store = options.store 23 | this.servicePath = options.servicePath 24 | } 25 | 26 | /** 27 | * Prepare new "instances" outside of store 28 | * 29 | * Functionally upgrades plain data to a service model "instance". 30 | * - flags each record with `__isSetup` to avoid duplicate work. 31 | */ 32 | new(data: AnyData = {}) { 33 | const asInstance = this.store.new(data) 34 | return reactive(asInstance) 35 | } 36 | 37 | /* service methods clone params */ 38 | 39 | async find(_params?: MaybeRef>) { 40 | const params = getParams(_params) 41 | const result = await this.service.find(params as FeathersParams) 42 | return result 43 | } 44 | 45 | async findOne(_params?: MaybeRef>) { 46 | const params = getParams(_params) 47 | params.query = params.query || {} 48 | params.query.$limit = 1 49 | const result = await this.service.find(params as FeathersParams) 50 | const item = (result.data || result)[0] || null 51 | return item 52 | } 53 | 54 | async count(_params?: MaybeRef>) { 55 | const params = getParams(_params) 56 | params.query = params.query || {} 57 | params.query.$limit = 0 58 | const result = await this.service.find(params as FeathersParams) 59 | return result 60 | } 61 | 62 | async get(id: Id, _params?: MaybeRef>) { 63 | const params = getParams(_params) 64 | const result = await this.service.get(id, params) 65 | return result 66 | } 67 | 68 | async create(data: AnyData) { 69 | const result = await this.service.create(data) 70 | return result 71 | } 72 | 73 | async patch(id: Id, data: AnyData, _params?: MaybeRef>) { 74 | const params = getParams(_params) 75 | const result = await this.service.patch(id, data, params) 76 | return result 77 | } 78 | 79 | async remove(id: Id, _params?: MaybeRef>) { 80 | const params = getParams(_params) 81 | const result = await this.service.remove(id, params) 82 | return result 83 | } 84 | 85 | /* store methods accept refs and don't copy params */ 86 | 87 | findInStore(params?: MaybeRef>) { 88 | const result = this.store.findInStore(params) 89 | return { 90 | ...result, 91 | data: computed(() => { 92 | return result.data.value.map((i: any) => convertData(this, i)) 93 | }), 94 | } 95 | } 96 | 97 | findOneInStore(params?: MaybeRef>) { 98 | const result = this.store.findOneInStore(params) 99 | return result 100 | } 101 | 102 | countInStore(params?: MaybeRef>) { 103 | const result = this.store.countInStore(params) 104 | return result 105 | } 106 | 107 | getFromStore(id: Id, params?: MaybeRef>): ComputedRef> { 108 | const result = this.store.getFromStore(id, params) 109 | return result 110 | } 111 | 112 | createInStore(data: AnyData) { 113 | const result = this.store.createInStore(data) 114 | return result 115 | } 116 | 117 | patchInStore( 118 | idOrData: MaybeRef, 119 | data: MaybeRef = {}, 120 | params: MaybeRef> = {}, 121 | ) { 122 | const result = this.store.patchInStore(idOrData, data, params) 123 | return result 124 | } 125 | 126 | removeFromStore(id?: Id, params?: MaybeRef>) { 127 | const item = id != null ? this.getFromStore(id).value : null 128 | if (item) { 129 | const result = this.store.removeFromStore(item) 130 | return result 131 | } else if (id == null && unref(params)?.query) { 132 | const result = this.store.removeByQuery(params) 133 | return result 134 | } 135 | } 136 | 137 | /* hybrid methods */ 138 | 139 | useFind(params: ComputedRef, options?: UseFindOptions) { 140 | const _params = isRef(params) ? params : ref(params) 141 | return useFind(_params, options, { service: this }) 142 | } 143 | 144 | useGet(id: MaybeRef, params: MaybeRef = ref({})) { 145 | const _id = isRef(id) ? id : ref(id) 146 | const _params = isRef(params) ? params : ref(params) 147 | return useGet(_id, _params, { service: this }) 148 | } 149 | 150 | useGetOnce(_id: MaybeRef, params: MaybeRef = {}) { 151 | const _params = isRef(params) ? params : ref(params) 152 | Object.assign(_params.value, { immediate: false }) 153 | const results = this.useGet(_id, _params) 154 | results.queryWhen(() => !results.data.value) 155 | results.get() 156 | return results 157 | } 158 | 159 | /* events */ 160 | 161 | on(eventName: string | symbol, listener: (...args: any[]) => void) { 162 | return this.service.on(eventName, listener) 163 | } 164 | 165 | emit(eventName: string | symbol, ...args: any[]): boolean { 166 | return this.service.emit(eventName, ...args) 167 | } 168 | 169 | removeListener(eventName: string | symbol, listener: (...args: any[]) => void) { 170 | return this.service.removeListener(eventName, listener) 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /pages/app/contacts.vue: -------------------------------------------------------------------------------- 1 | 2 | 73 | 74 | 180 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/types.ts: -------------------------------------------------------------------------------- 1 | import type { ComputedRef, Ref } from 'vue-demi' 2 | import type { Id, Query } from '@feathersjs/feathers' 3 | import type { MaybeRef } from '@vueuse/core' 4 | import type { AnyData, Paginated, Params, QueryInfo } from '../types' 5 | import type { useFind } from '../use-find-get/use-find' 6 | 7 | export interface FindResponseAlwaysData { 8 | data: M | M[] 9 | limit?: number 10 | skip?: number 11 | total?: number 12 | } 13 | 14 | // event locks 15 | export type EventName = 'created' | 'patched' | 'updated' | 'removed' 16 | export type EventLocks = { 17 | [key in EventName]: { 18 | [key: string]: boolean 19 | } 20 | } 21 | 22 | export type RequestTypeById = 'create' | 'patch' | 'update' | 'remove' 23 | 24 | interface QueryPagination { 25 | $limit: number 26 | $skip: number 27 | } 28 | 29 | export interface MostRecentQuery { 30 | pageId: string 31 | pageParams: QueryPagination 32 | queriedAt: number 33 | query: Query 34 | queryId: string 35 | queryParams: Query 36 | total: number 37 | } 38 | 39 | /** 40 | * Pagination state types, below, are for the basic format shown here. 41 | * 42 | * 43 | * { 44 | * // PaginationState 45 | * pagination : { 46 | * defaultLimit: 25, 47 | * defaultSkip: 0, 48 | * 49 | * // PaginationStateQid 50 | * default: { 51 | * mostRecent: { 52 | * query: {}, 53 | * queryId: '{}', 54 | * queryParams: {}, 55 | * pageId: '{$limit:25,$skip:0}', 56 | * pageParams: { $limit: 25, $skip: 0 }, 57 | * queriedAt: 1538594642481 58 | * }, 59 | * 60 | * // PaginationStateQuery 61 | * '{}': { 62 | * total: 155, 63 | * queryParams: {}, 64 | * 65 | * // PaginationStatePage 66 | * '{$limit:25,$skip:0}': { 67 | * pageParams: { $limit: 25, $skip: 0 }, 68 | * ids: [ 1, 2, 3, 4, '...etc', 25 ], 69 | * queriedAt: 1538594642481 70 | * } 71 | * } 72 | * } 73 | * } 74 | * } 75 | * 76 | */ 77 | export interface PaginationStatePage { 78 | ids: Id[] 79 | pageParams: QueryPagination 80 | queriedAt: number 81 | ssr: boolean 82 | } 83 | export type PaginationStateQuery = { [pageId: string]: PaginationStatePage } & { 84 | queryParams: Query 85 | total: number 86 | } 87 | export type PaginationStateQid = { [qid: string]: PaginationStateQuery } & { mostRecent: MostRecentQuery } 88 | export type PaginationState = { [qid: string]: PaginationStateQid } & { defaultLimit: number; defaultSkip: number } 89 | 90 | export interface HandleFindResponseOptions { 91 | params: Params 92 | response: M[] | Paginated 93 | } 94 | export interface HandleFindErrorOptions { 95 | params: Params 96 | error: any 97 | } 98 | 99 | // The find action will always return data at params.data, even for non-paginated requests. 100 | // export type FindFn = InstanceType> = ( 101 | // params?: MaybeRef, 102 | // ) => Promise> 103 | // export type GetFn = InstanceType> = ( 104 | // id?: Id, 105 | // params?: MaybeRef, 106 | // ) => Promise 107 | // export type GetFnWithId = InstanceType> = ( 108 | // id: Id, 109 | // params?: MaybeRef, 110 | // ) => Promise 111 | // export type UseGetFn = InstanceType> = ( 112 | // _id: MaybeRef, 113 | // _params?: MaybeRef, 114 | // ) => Get 115 | 116 | interface Association { 117 | name: string 118 | service: any 119 | type: 'find' | 'get' 120 | } 121 | export type BaseModelAssociations = Record 122 | 123 | export interface UpdatePaginationForQueryOptions { 124 | qid: string 125 | response: any 126 | query: any 127 | preserveSsr: boolean 128 | } 129 | 130 | export interface ModelInstanceOptions { 131 | /** 132 | * is creating clone 133 | */ 134 | clone?: boolean 135 | } 136 | 137 | export interface BaseModelModifierOptions { 138 | store: any 139 | } 140 | 141 | export interface CloneOptions { 142 | useExisting?: boolean 143 | } 144 | 145 | export interface UseCloneOptions { 146 | useExisting?: boolean 147 | deep?: boolean 148 | } 149 | 150 | export interface QueryWhenContext { 151 | items: ComputedRef 152 | queryInfo: QueryInfo 153 | /** 154 | * Pagination data for the current qid 155 | */ 156 | qidData: PaginationStateQid 157 | queryData: PaginationStateQuery 158 | pageData: PaginationStatePage 159 | isPending: ComputedRef 160 | haveBeenRequested: ComputedRef 161 | haveLoaded: ComputedRef 162 | error: any 163 | } 164 | 165 | export type QueryWhenFunction = ComputedRef<(context: QueryWhenContext) => boolean> 166 | 167 | export interface GetClassParams extends Params { 168 | query?: Q 169 | onServer?: boolean 170 | immediate?: boolean 171 | } 172 | export interface GetClassParamsStandalone extends GetClassParams { 173 | store: any 174 | } 175 | export interface FindClassParams extends Params { 176 | query: Q 177 | onServer?: boolean 178 | qid?: string 179 | immediate?: boolean 180 | watch?: boolean 181 | } 182 | export interface FindClassParamsStandalone extends FindClassParams { 183 | store: any 184 | } 185 | 186 | export interface UseFindWatchedOptions { 187 | params: Params | ComputedRef | null> 188 | fetchParams?: ComputedRef | null | undefined> 189 | queryWhen?: ComputedRef | QueryWhenFunction 190 | qid?: string 191 | local?: boolean 192 | immediate?: boolean 193 | } 194 | export interface UseFindWatchedOptionsStandalone extends UseFindWatchedOptions { 195 | model: any 196 | } 197 | export interface UseFindState { 198 | debounceTime: null | number 199 | qid: string 200 | isPending: boolean 201 | haveBeenRequested: boolean 202 | haveLoaded: boolean 203 | error: null | Error 204 | latestQuery: null | object 205 | isLocal: boolean 206 | request: Promise | null 207 | } 208 | export interface UseFindComputed { 209 | items: ComputedRef 210 | servicePath: ComputedRef 211 | paginationData: ComputedRef 212 | isSsr: ComputedRef 213 | } 214 | 215 | export interface UseGetOptions { 216 | id: Ref | ComputedRef | null 217 | params?: Ref> 218 | queryWhen?: Ref 219 | local?: boolean 220 | immediate?: boolean 221 | } 222 | export interface UseGetOptionsStandalone extends UseGetOptions { 223 | model: any 224 | } 225 | export interface UseGetState { 226 | isPending: boolean 227 | hasBeenRequested: boolean 228 | hasLoaded: boolean 229 | error: null | Error 230 | isLocal: boolean 231 | request: Promise | null 232 | } 233 | export interface UseGetComputed { 234 | item: ComputedRef 235 | servicePath: ComputedRef 236 | isSsr: ComputedRef 237 | } 238 | 239 | export interface AssociateFindUtils extends ReturnType { 240 | useFind: (params: MaybeRef) => any 241 | } 242 | 243 | export type HandledEvents = 'created' | 'patched' | 'updated' | 'removed' 244 | export type HandleEventsFunction = (item: M, ctx: { model: M; models: any }) => any 245 | 246 | export type HandleEvents = 247 | | { 248 | [event in HandledEvents]: HandleEventsFunction 249 | } 250 | | boolean 251 | 252 | export type onReadFn = (item: M) => M | Partial 253 | export type beforeWriteFn = (item: M) => M | Partial 254 | export type AssignFn = (dest: M, src: M) => M | Partial 255 | -------------------------------------------------------------------------------- /feathers-pinia-3/stores/local-queries.ts: -------------------------------------------------------------------------------- 1 | import { MaybeRef } from '@vueuse/core' 2 | import type { Id } from '@feathersjs/feathers' 3 | import { _ } from '@feathersjs/commons' 4 | import { computed, unref } from 'vue-demi' 5 | import { filterQuery, select, sorter } from '@feathersjs/adapter-commons' 6 | import sift from 'sift' 7 | import fastCopy from 'fast-copy' 8 | import type { AnyData, Params } from '../types' 9 | import { deepUnref, getArray } from '../utils' 10 | import { sqlOperations } from './utils-custom-operators' 11 | import type { StorageMapUtils } from './storage' 12 | 13 | interface UseServiceLocalOptions { 14 | idField: string 15 | itemStorage: StorageMapUtils 16 | tempStorage?: StorageMapUtils 17 | cloneStorage?: StorageMapUtils 18 | addItemToStorage: any 19 | whitelist?: string[] 20 | paramsForServer?: string[] 21 | customSiftOperators?: Record 22 | } 23 | 24 | const FILTERS = ['$sort', '$limit', '$skip', '$select'] 25 | const additionalOperators = [ 26 | '$in', 27 | '$nin', 28 | '$exists', 29 | 'eq', 30 | 'ne', 31 | '$mod', 32 | '$all', 33 | '$not', 34 | '$size', 35 | '$type', 36 | '$regex', 37 | '$options', 38 | '$where', 39 | '$elemMatch', 40 | ] 41 | 42 | export function useServiceLocal(options: UseServiceLocalOptions) { 43 | const { 44 | idField, 45 | itemStorage, 46 | tempStorage, 47 | cloneStorage, 48 | addItemToStorage, 49 | paramsForServer = [], 50 | whitelist = [], 51 | customSiftOperators = {}, 52 | } = options 53 | 54 | const operations = Object.assign({}, sqlOperations, customSiftOperators) 55 | 56 | /** @private */ 57 | const _filterQueryOperators = computed(() => { 58 | return additionalOperators.concat(whitelist || []).concat(Object.keys(operations)) 59 | }) 60 | 61 | const filterItems = (params: Params, startingValues: M[] = []) => { 62 | params = { ...unref(params) } || {} 63 | const _paramsForServer = paramsForServer 64 | const q = _.omit(params.query || {}, ..._paramsForServer) 65 | 66 | const { query, filters } = filterQuery(q, { 67 | operators: _filterQueryOperators.value, 68 | }) 69 | let values = startingValues.concat(itemStorage.list.value) 70 | 71 | if (tempStorage && params.temps) values.push(...tempStorage.list.value) 72 | 73 | if (filters.$or) query.$or = filters.$or 74 | 75 | if (filters.$and) query.$and = filters.$and 76 | 77 | values = values.filter(sift(query, { operations })) 78 | return { values, filters } 79 | } 80 | 81 | function findInStore(_params: MaybeRef>) { 82 | const result = computed(() => { 83 | const params = unref(_params) 84 | // clean up any nested refs 85 | if (params.query) params.query = deepUnref(params.query) 86 | 87 | const filtered = filterItems(params) 88 | const filters = filtered.filters 89 | let values = filtered.values 90 | 91 | const total = values.length 92 | 93 | if (filters.$sort) values.sort(sorter(filters.$sort)) 94 | 95 | if (filters.$skip) values = values.slice(filters.$skip) 96 | 97 | if (typeof filters.$limit !== 'undefined') values = values.slice(0, filters.$limit) 98 | 99 | return { 100 | total, 101 | limit: filters.$limit || 0, 102 | skip: filters.$skip || 0, 103 | data: params.clones 104 | ? values.map((v: any) => (v.clone ? v.clone(undefined, { useExisting: true }) : v)) 105 | : values, 106 | } 107 | }) 108 | return { 109 | total: computed(() => result.value.total), 110 | limit: computed(() => result.value.limit), 111 | skip: computed(() => result.value.skip), 112 | data: computed(() => result.value.data), 113 | } 114 | } 115 | 116 | function findOneInStore(params: MaybeRef>) { 117 | const result = findInStore(params) 118 | const item = computed(() => { 119 | return result.data.value[0] || null 120 | }) 121 | return item 122 | } 123 | 124 | function countInStore(params: MaybeRef>) { 125 | const value = computed(() => { 126 | params = { ...unref(params) } 127 | 128 | if (!params.query) throw new Error('params must contain a query object') 129 | 130 | params.query = _.omit(params.query, ...FILTERS) 131 | return findInStore(params).total.value 132 | }) 133 | return value 134 | } 135 | 136 | const getFromStore = (id: MaybeRef, params?: Params) => { 137 | return computed((): M | null => { 138 | id = unref(id) 139 | params = fastCopy(unref(params) || {}) 140 | if (params.query) params.query = deepUnref(params.query) 141 | 142 | let item = null 143 | const existingItem = itemStorage.getItem(id as Id) && select(params, idField)(itemStorage.getItem(id as Id)) 144 | const tempItem = 145 | tempStorage && tempStorage.getItem(id as Id) && select(params, '__tempId')(tempStorage.getItem(id as Id)) 146 | 147 | if (existingItem) item = existingItem 148 | else if (tempItem) item = tempItem 149 | 150 | const toReturn = params.clones && item.clone ? item.clone(undefined, { useExisting: true }) : item || null 151 | return toReturn 152 | }) 153 | } 154 | 155 | /** 156 | * Write records to the store. 157 | * @param data a single record or array of records. 158 | * @returns data added or modified in the store. If you pass an array, you get an array back. 159 | */ 160 | function createInStore>(data: N): N { 161 | const { items, isArray } = getArray(unref(data)) 162 | 163 | const _items = items.map((item: N) => { 164 | const stored = addItemToStorage(unref(item)) 165 | return stored 166 | }) 167 | 168 | return isArray ? _items : _items[0] 169 | } 170 | 171 | // TODO 172 | function patchInStore( 173 | _idOrData: MaybeRef, 174 | _data: MaybeRef = {}, 175 | _params: MaybeRef> = {}, 176 | ) { 177 | const idOrData = unref(_idOrData) 178 | const data = unref(_data) 179 | const params = unref(_params) 180 | 181 | // patches provided items using the `data` from the closure scope. 182 | function updateItems(items: any[]) { 183 | const patched = items 184 | .map((item: M | Id | null) => { 185 | item = unref(item) 186 | // convert ids to items from the store 187 | if (typeof item === 'number' || typeof item === 'string') { 188 | item = getFromStore(item as Id).value 189 | } 190 | if (item == null) return null 191 | 192 | const toWrite = { ...item, ...data } 193 | const stored = addItemToStorage(toWrite) 194 | return stored 195 | }) 196 | .filter((i) => i) 197 | return patched 198 | } 199 | 200 | if (idOrData === null) { 201 | // patching multiple cannot use an empty array 202 | if (params?.query && !Object.keys(params?.query).length) { 203 | throw new Error( 204 | `cannot perform multiple patchInStore with an empty query. You must explicitly provide a query. To patch all items, try using a query that matches all items, like "{ id: { $exists: true } }"`, 205 | ) 206 | } 207 | // patch by query 208 | const fromStore = findInStore(params).data.value 209 | const items = updateItems(fromStore) 210 | 211 | return items 212 | } else { 213 | // patch provided data 214 | const { items, isArray } = getArray(idOrData) 215 | const patchedItems = updateItems(items) 216 | 217 | return isArray ? patchedItems : patchedItems[0] 218 | } 219 | } 220 | 221 | /** 222 | * If a clone is provided, it removes the clone from the store. 223 | * If a temp is provided, it removes the temp from the store. 224 | * If an item is provided, the item and its associated temp and clone are removed. 225 | * If a string is provided, it removes any item, temp, or clone from the stores. 226 | * @param data 227 | */ 228 | function removeFromStore(data: M | M[] | null, params?: Params) { 229 | if (data === null && params?.query && Object.keys(params?.query).length) { 230 | const clones = cloneStorage ? cloneStorage.list.value : [] 231 | const { values } = filterItems(params, clones) 232 | const result = removeItems(values) 233 | return result 234 | } else if (data !== null) { 235 | removeItems(data) 236 | } 237 | 238 | return data 239 | } 240 | 241 | function removeItems(data: M | M[]) { 242 | const { items } = getArray(data) 243 | items.forEach((item: M) => { 244 | if (typeof item === 'string') { 245 | itemStorage.removeItem(item) 246 | tempStorage?.removeItem(item) 247 | cloneStorage?.removeItem(item) 248 | } else { 249 | if ((item as M).__isClone) return cloneStorage?.remove(item as M) 250 | 251 | if ((item as M).__isTemp) return tempStorage?.remove(item as M) 252 | 253 | itemStorage.remove(item) 254 | tempStorage?.remove(item) 255 | cloneStorage?.remove(item) 256 | } 257 | }) 258 | return data 259 | } 260 | 261 | return { 262 | findInStore, 263 | findOneInStore, 264 | countInStore, 265 | getFromStore, 266 | createInStore, 267 | patchInStore, 268 | removeFromStore, 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /feathers-pinia-3/use-find-get/use-find.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-use-before-define */ 2 | import type { ComputedRef, Ref } from 'vue-demi' 3 | import { computed, ref, unref, watch } from 'vue-demi' 4 | import { _ } from '@feathersjs/commons' 5 | import { useDebounceFn } from '@vueuse/core' 6 | import stringify from 'fast-json-stable-stringify' 7 | import { deepUnref, getExtendedQueryInfo } from '../utils' 8 | import type { AnyData, ExtendedQueryInfo, Paginated, Params, Query } from '../types' 9 | import { itemsFromPagination } from './utils' 10 | import { usePageData } from './utils-pagination' 11 | import type { UseFindGetDeps, UseFindOptions, UseFindParams } from './types' 12 | 13 | export function useFind(params: ComputedRef, options: UseFindOptions = {}, deps: UseFindGetDeps) { 14 | const { pagination, debounce = 100, immediate = true, watch: _watch = true, paginateOn = 'client' } = options 15 | const { service } = deps 16 | const { store } = service 17 | 18 | /** PARAMS **/ 19 | const qid = computed(() => params.value?.qid || 'default') 20 | const limit = pagination?.limit || ref(params.value?.query?.$limit || store.defaultLimit) 21 | const skip = pagination?.skip || ref(params.value?.query?.$skip || 0) 22 | 23 | const paramsWithPagination = computed(() => { 24 | const query = deepUnref(params.value?.query || {}) 25 | return { 26 | ...params.value, 27 | query: { 28 | ...query, 29 | $limit: limit.value, 30 | $skip: skip.value, 31 | }, 32 | } 33 | }) 34 | const paramsWithoutPagination = computed(() => { 35 | const queryShallowCopy = deepUnref({ ...(params.value?.query || {}) }) 36 | const query = _.omit(queryShallowCopy, '$limit', '$skip') 37 | return { ...params.value, query } 38 | }) 39 | 40 | /** REQUEST STATE **/ 41 | const isPending = ref(false) 42 | const haveBeenRequested = ref(false) 43 | const haveLoaded = ref(false) 44 | const error = ref(null) 45 | const clearError = () => (error.value = null) 46 | 47 | /** Cached Params **/ 48 | const cachedParams = ref(deepUnref(params.value || {})) 49 | function updateCachedParams() { 50 | if (stringify(cachedParams.value) !== stringify(paramsWithPagination.value)) 51 | cachedParams.value = paramsWithPagination.value 52 | } 53 | 54 | /** STORE ITEMS **/ 55 | const localParams = computed(() => { 56 | const beforeCurrent = itemsBeforeCurrent.value 57 | const adjustedSkip = skip.value + (beforeCurrent.length - skip.value) 58 | const params = { 59 | ...paramsWithPagination.value, 60 | query: { 61 | ...paramsWithPagination.value.query, 62 | $limit: limit.value, 63 | $skip: adjustedSkip, 64 | }, 65 | } 66 | return params 67 | }) 68 | 69 | const data = computed(() => { 70 | if (paginateOn === 'server') { 71 | const values = itemsFromPagination(store, service, cachedParams.value) 72 | return values 73 | } 74 | else if (paginateOn === 'hybrid') { 75 | const result = service.findInStore(deepUnref(localParams)).data.value 76 | return result.filter((i: any) => i) 77 | } 78 | else { 79 | const result = service.findInStore(deepUnref(paramsWithPagination)).data.value 80 | return result.filter((i: any) => i) 81 | } 82 | }) 83 | const itemsBeforeCurrent = computed(() => { 84 | const whichQuery = isPending.value ? cachedQuery.value : currentQuery.value 85 | if (whichQuery == null) 86 | return [] 87 | 88 | const allItems = allLocalData.value 89 | const firstOfCurrentPage = whichQuery.items.find((i: any) => i) 90 | const indexInItems = allItems.findIndex((i: any) => i[store.idField] === firstOfCurrentPage[store.idField]) 91 | // if indexInItems is higher than skip, use the skip value instead 92 | const adjustedIndex = Math.min(indexInItems, skip.value) 93 | const beforeCurrent = allItems.slice(0, adjustedIndex) 94 | return beforeCurrent 95 | }) 96 | const allLocalData = computed(() => { 97 | const whichQuery = isPending.value ? cachedQuery.value : currentQuery.value 98 | if (whichQuery == null) 99 | return [] 100 | 101 | const allItems = service.findInStore(deepUnref(paramsWithoutPagination.value)).data.value 102 | return allItems 103 | }) 104 | 105 | /** QUERY WHEN **/ 106 | let queryWhenFn = () => true 107 | const queryWhen = (_queryWhenFn: () => boolean) => { 108 | queryWhenFn = _queryWhenFn 109 | } 110 | // returns cached query data from the store BEFORE the request is sent. 111 | const cachedQuery = computed(() => { 112 | const qidState: any = store.pagination[qid.value] 113 | if (!qidState) 114 | return null 115 | 116 | const queryInfo = store.getQueryInfo(cachedParams.value) 117 | const extendedInfo = getExtendedQueryInfo({ queryInfo, service, store, qid }) 118 | return extendedInfo 119 | }) 120 | 121 | const currentQuery = computed(() => { 122 | const qidState: any = store.pagination[qid.value] 123 | if (!qidState) 124 | return null 125 | 126 | const queryInfo = store.getQueryInfo(paramsWithPagination.value) 127 | const extendedInfo = getExtendedQueryInfo({ queryInfo, service, store, qid }) 128 | return extendedInfo 129 | }) 130 | 131 | /** QUERIES **/ 132 | const queries: Ref = ref([]) // query info after the response returns 133 | const latestQuery = computed(() => { 134 | return queries.value[queries.value.length - 1] || null 135 | }) 136 | const previousQuery = computed(() => { 137 | return queries.value[queries.value.length - 2] || null 138 | }) 139 | 140 | /** SERVER FETCHING **/ 141 | const requestCount = ref(0) 142 | const request = ref> | null>(null) 143 | 144 | // pulled into its own function so it can be called from `makeRequest` or `find` 145 | function setupPendingState() { 146 | // prevent setting pending state for cached ssr requests 147 | if (currentQuery.value?.ssr) 148 | return 149 | 150 | if (!haveBeenRequested.value) 151 | haveBeenRequested.value = true // never resets 152 | clearError() 153 | if (!isPending.value) 154 | isPending.value = true 155 | if (haveLoaded.value) 156 | haveLoaded.value = false 157 | } 158 | 159 | async function find(__params?: Params) { 160 | // When `paginateOn: 'server'` is enabled, the computed params will always be used, __params ignored. 161 | const ___params = unref(['hybrid', 'server'].includes(paginateOn) ? (paramsWithPagination as any) : __params) 162 | 163 | // if queryWhen is falsey, return early with dummy data 164 | if (!queryWhenFn()) 165 | return Promise.resolve({ data: [] as AnyData[] } as Paginated) 166 | 167 | setupPendingState() 168 | requestCount.value++ 169 | 170 | try { 171 | const response = await service.find(___params as any) 172 | 173 | // Keep the two most-recent queries 174 | if (response.total) { 175 | const queryInfo = store.getQueryInfo(paramsWithPagination.value) 176 | const extendedQueryInfo = getExtendedQueryInfo({ queryInfo, service, store, qid }) 177 | if (extendedQueryInfo) 178 | queries.value.push(extendedQueryInfo as unknown as ExtendedQueryInfo) 179 | if (queries.value.length > 2) 180 | queries.value.shift() 181 | } 182 | haveLoaded.value = true 183 | 184 | return response 185 | } 186 | catch (err: any) { 187 | error.value = err 188 | throw err 189 | } 190 | finally { 191 | isPending.value = false 192 | } 193 | } 194 | const findDebounced = useDebounceFn(find, debounce) 195 | 196 | /** Query Gatekeeping **/ 197 | const makeRequest = async () => { 198 | // If params are null, do nothing 199 | if (params.value === null) 200 | return 201 | 202 | // If we already have data for the currentQuery, update the cachedParams immediately 203 | if (currentQuery.value) 204 | updateCachedParams() 205 | 206 | // if the query passes queryWhen, setup the state before the debounce timer starts. 207 | if (queryWhenFn()) 208 | setupPendingState() 209 | 210 | request.value = findDebounced() 211 | await request.value 212 | 213 | // cache the params to update the computed `data`` 214 | updateCachedParams() 215 | } 216 | 217 | /** Pagination Data **/ 218 | const total = computed(() => { 219 | if (['server', 'hybrid'].includes(paginateOn)) { 220 | const whichQuery = currentQuery.value || cachedQuery.value 221 | return whichQuery?.total || 0 222 | } 223 | else { 224 | const count = service.countInStore(paramsWithoutPagination.value) 225 | return count.value 226 | } 227 | }) 228 | const pageData = usePageData({ limit, skip, total, request }) 229 | const { pageCount, currentPage, canPrev, canNext, toStart, toEnd, toPage, next, prev } = pageData 230 | 231 | /** Query Watching **/ 232 | if (['server', 'hybrid'].includes(paginateOn) && _watch) { 233 | watch( 234 | paramsWithPagination, 235 | () => { 236 | makeRequest() 237 | }, 238 | { immediate: false }, 239 | ) 240 | 241 | if (immediate) 242 | makeRequest() 243 | } 244 | 245 | if (paginateOn === 'server') { 246 | // watch realtime events and re-query 247 | // TODO: only re-query when relevant 248 | service.on('created', () => { 249 | makeRequest() 250 | }) 251 | service.on('patched', () => { 252 | makeRequest() 253 | }) 254 | 255 | // if the current list had an item removed, re-query. 256 | service.on('removed', () => { 257 | // const id = item[service.store.idField] 258 | // const currentIds = data.value.map((i: any) => i[service.store.idField]) 259 | // if (currentIds.includes(id)) 260 | makeRequest() 261 | }) 262 | } 263 | 264 | return { 265 | paramsWithPagination, 266 | isSsr: computed(() => { 267 | // hack: read total early during SSR to prevent hydration mismatch 268 | setTimeout(() => { 269 | ref(total.value) 270 | }, 0) 271 | return store.isSsr 272 | }), // ComputedRef 273 | qid, // WritableComputedRef 274 | 275 | // Data 276 | data, // ComputedRef 277 | allLocalData, // ComputedRef 278 | total, // ComputedRef 279 | limit, // Ref 280 | skip, // Ref 281 | 282 | // Queries 283 | currentQuery, // ComputedRef | null> 284 | cachedQuery, // ComputedRef | null> 285 | latestQuery, // ComputedRef 286 | previousQuery, // ComputedRef 287 | 288 | // Requests & Watching 289 | find: makeRequest, // FindFn 290 | request, // Ref>> 291 | requestCount, // Ref 292 | queryWhen, // (queryWhenFn: () => boolean) => void 293 | 294 | // Request State 295 | isPending: computed(() => isPending.value), // ComputedRef 296 | haveBeenRequested: computed(() => haveBeenRequested.value), // ComputedRef 297 | haveLoaded: computed(() => haveLoaded.value), // ComputedRef 298 | error: computed(() => error.value), // ComputedRef 299 | clearError, // () => void 300 | 301 | // Pagination Utils 302 | pageCount, // Ref 303 | currentPage, // Ref 304 | canPrev, // ComputedRef 305 | canNext, // ComputedRef 306 | next, // () => Promise 307 | prev, // () => Promise 308 | toStart, // () => Promise 309 | toEnd, // () => Promise 310 | toPage, // (page: number) => Promise 311 | } 312 | } 313 | --------------------------------------------------------------------------------