Skip to content

Changelog ​

Every release of @guebbit/vue-toolkit, newest first. A BREAKING change only ships in a major version, with its migration next to it. Upgrading from 4.x: see the migration guide.

5.2.0 ​

Added ​

  • Every watch* handle (watchTarget, watchAll, watchByParent, watchAny, watchSearch, and CRUD's watchOne/watchList) exposes its query's state: data (the watched data as cached now, following local edits), isFetching, isPending and dataUpdatedAt. A per-watcher spinner no longer needs an isLoading key of its own. IWatchHandle carries the four new fields.
  • watchAll and watchByParent take onSuccess / onError / onSettled, like watchTarget and watchSearch. The context they receive is the bucket key (watchAll) or the parent id (watchByParent). IWatchListSettings gains two optional type parameters for them, <T, C>; its bare form keeps working.
  • invalidateTarget(id) and invalidateLists(): mark one record (an alias reaches the record it points at, and every other alias of it) or every list of the current dependsOn stale. Watched ones refetch; the promise settles once they have.
  • watchTarget, CRUD's watchOne, useStructureSearchApi's filters source and activateAutoHydrate accept a plain value as well as a Ref or a getter (MaybeRefOrGetter), like watchByParent already did.
  • The package ships source maps and declaration maps, with src/: debuggers and "Go to definition" land on the TypeScript source.
  • Docs: the site shows the version it documents and each page's last update, has local search, "edit this page" links, a changelog page, and a "Your first CRUD screen" guide.

Changed ​

  • itemDictionary and parentHasMany of useStructureRestApi / useStructureSearchApi / useStructureCrudApi are typed ComputedRef (read-only views of the cache, as they always were at runtime) instead of Ref. Code that only reads them is unaffected; a write to them never had any effect and is now a type error.
  • watchAny's return type is IWatchHandle<F | undefined>, which now includes data; the separate & { data } intersection is gone. Same shape as before.
  • handleSubmit (useStructureFormValidation) and dismissMessage (useNotificationsStore) now call their handler synchronously instead of one microtask later. The handler gets form.value as it is when handleSubmit is called; a synchronous throw still becomes a rejection.

Fixed ​

  • searchNow (useStructureCrudApi) without a search operation rejected, but still reset the current page to 1. It now leaves the page alone.
  • updateTarget without an explicit id stored an id-less response (an acknowledgement, a merge: true partial) under a fabricated id — a phantom record, with a console warning — instead of on the record being updated (itemData's own id). The response now always lands on the resolved id the update addressed.
  • A partial: true list fetch (fetchAll, fetchByParent, fetchPaginate, fetchSearch and their watchers) that brought in a record not cached yet moved lastInsertedIdentifier, as if the record had just been created. It is a server answer like any other fetch, so it no longer does. Freshness is unchanged: a partial record still keeps its own (a new one stays stale).

5.1.1 ​

Fixed ​

  • applyServerErrors: when onUnmapped is given, it now also receives a rejection that carried nothing — a network failure, a bare 500, a 409 with an empty body — as onUnmapped([]), and the call returns true. Before, such a rejection reached nothing: onUnmapped stayed silent, nothing landed on the form, and the only signal was a false return that callers using onUnmapped did not check, so a failed submit showed no error at all. An empty array means "the rejection said nothing usable; show your generic error". showFormErrors stays off in that case (the form itself displays nothing). Without onUnmapped nothing changes: the toolkit has no text of its own to show, so the call still returns false. A caller that also reports on false stops getting that false when it passes onUnmapped, which removes a double-report rather than creating one.

5.1.0 ​

Added ​

  • useNotificationsStore: persistent messages — ones that stay until the user deletes them and that often belong to someone else (a server inbox, localStorage). One store, one addMessage: the app decides where a message comes from and where a deletion goes.
    • addMessage(message, type?, timeout?, options?) takes an optional 4th argument, IAddMessageOptions (id, persistent, createdAt, meta, onDismiss), exported from the package. Every existing call keeps working.
    • A caller-owned id already in history updates that entry in place (message, type, persistent, meta, onDismiss), keeps visible and createdAt, and does not re-arm the timeout — a refetch never brings back a toast the user closed.
    • dismissMessage(id): Promise<void>: the user is done with a message. It leaves history optimistically, then calls its onDismiss; if that rejects, the message goes back to its original index and the error is re-thrown. Unknown ids resolve as a no-op.
    • persistentMessages: history filtered by persistent, newest createdAt first.
    • IToastMessage gains persistent, createdAt and meta.
    • hideMessage (leaves the toast stack) and removeMessage (the app drops it) never call onDismiss, so a sync that finds a message gone at its source does not echo a delete back.
    • onDismiss callbacks are kept outside the reactive state, so history stays serializable.
  • npm run try:frontend: builds, packs, and installs the tarball into a frontend checkout ($FRONTEND_DIR, default ../boilerplate-vue-frontend) with --no-save, to try a change before publishing. See the new Development guide.

Changed ​

  • Contributor scripts: complete:check (the read-only gate) is now complete, and the fixing pipeline that was complete is now complete:fix. The pre-commit hook, the release workflow and prepublishOnly run complete.

5.0.0 ​

useStructureRestApi is rebuilt directly on TanStack Query as the single source of truth for records, lists, searches, freshness, invalidation, and loading. TanStack came in with 2.0 (afb162b) only to replace the old TTL timestamps: the hand-rolled dictionary stayed around it through 4.x, so the toolkit ran two caches, and TanStack was used as a clock that never knew what was actually on screen. This release closes four related frontend bugs traced to that split:

  • the next user to log in could still see the previous user's data (nothing scoped the cache to who the data belonged to);
  • a language switch emptied whatever page was on screen (same root cause: nothing kept fetched pages tied to a language);
  • a paginated list's pager could vanish on a cache hit (the server-reported total was never stored alongside the page that produced it);
  • another store's list could go stale on screen indefinitely (invalidating it did nothing, because nothing was watching it live).

All four are now structural, not patched: a resource's cache lives entirely in one shared QueryClient, scoped by a dependsOn getter (whose data, which language), and every watch* call is an active TanStack observer that reacts to invalidation on its own.

Changed ​

  • BREAKING — @guebbit/vue-toolkit now peers on @tanstack/vue-query ^5.103 instead of @tanstack/query-core. vue-query is query-core plus the Vue layer (it re-exports all of it and depends on an exact version internally), so installing @tanstack/query-core directly alongside it is a real failure, not a warning — two copies means a client built from the wrong one silently never fetches, and instanceof CancelledError answers false across the pair. Remove @tanstack/query-core from your own package.json, install @tanstack/vue-query, and set it up once per app:
    ts
    import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
    const queryClient = new QueryClient()
    app.use(VueQueryPlugin, { queryClient })
    4.x's private client set retry: false and networkMode: 'always'; a new QueryClient() keeps TanStack's defaults instead, so a watch* query retries a failure 3 times (in a browser) before reporting it, and queries and mutations pause while the browser reports itself offline. To keep the 4.x behaviour, set them on the client:
    ts
    new QueryClient({
        defaultOptions: {
            queries: { retry: false, networkMode: 'always' },
            mutations: { networkMode: 'always' }
        }
    })
    There is no private, toolkit-owned QueryClient any more — every useStructureRestApi/ useStructureSearchApi/useStructureCrudApi call either takes one explicitly via its queryClient option or finds it through this injection (which also works inside a Pinia setup store, on pinia >=2.1 — see below). One shared client per app is what makes cross-resource cache invalidation possible at all. The queryClient a resource returns is that shared client, so resource.queryClient.clear() now wipes every resource in the app: use resetAll() to drop one resource's data. The vue peer moves from >=3.0.0 to ^3.4 — one minor past vue-query's own floor (3.3): see Fixed, below, for why. On Vue 3.0–3.3, upgrade Vue to 3.4 or later first.
  • BREAKING — pinia peer floor moves from >=2.0.0 to ^2.1 || ^3 || ^4. Inside a component's setup(), a Pinia SETUP store finds the injection context on any Pinia version. Built outside a component instead (a router guard, main.ts: useStore(pinia)) it needed Pinia to wrap its own setup function in app.runWithContext, which only exists from 2.1 — before that, useQueryClient() inside one threw, since there was no injection context at all. This never worked outside a component on 2.0.x, so the new floor states the real requirement rather than removing support. Pass the queryClient option instead to work on any Pinia version — it needs no injection at all. The zod peer also moves from >=4.4.3 to ^4.4.3 (caret, not open-ended) — no consumer on a current major is affected.
  • BREAKING — loadingKey → resourceKey, and it is now required, with no random fallback. It's the key every query and mutation a resource makes starts with — what queryClient.invalidateQueries({queryKey:[resourceKey]}) addresses from another store, and what setQueryDefaults uses to keep records cached. A random default would silently defeat both for any resource that forgot to set one. The returned loadingKey field is renamed the same way: read resourceKey. It must also be unique per record type: every resource now shares one QueryClient (4.x gave each its own), so two resources under the same key share cache entries — user 1 and product 1 collide. Resources that shared a loadingKey for one combined spinner take distinct resourceKeys, read together with useIsLoading(['users', 'products']).
  • BREAKING — TTL → staleTime, both as a composable option and a per-call setting. "Time to live" implied the data gets removed once it expires; it doesn't — stale data keeps rendering while a fresh copy downloads, same as always. Only the name changed.
  • BREAKING — lastUpdateKey: 'x' → key: ['x'], now an array. It still namespaces independent buckets for an otherwise-identical list or fetchAny call, and is now also what isLoading(key) matches a call by — on queries and mutations (createTarget, updateTarget, deleteTarget, mutateAny take it for that). fetchTarget/fetchMultiple/ watchTarget take no key: a record has exactly one cache entry, keyed only by id. checkAny's first argument follows: checkAny('x') → checkAny(['x']).
  • BREAKING — per-call settings are typed per method: each method accepts only the fields it reads, and passing another is a type error. fetchTarget/fetchMultiple: forced, merge, staleTime. fetchAny: forced, staleTime, key. createTarget/deleteTarget/mutateAny: key. updateTarget: merge, key, applyResponse (IUpdateTargetSettings). watchTarget: forced, merge, staleTime plus its callbacks (IWatchTargetSettings). checkAll/ checkByParent/checkPaginate: key, staleTime; checkTarget/checkMultiple/checkAny: staleTime (checkAny's key is its first argument). List fetches and their watchers take the whole IFetchSettings.
  • BREAKING — mismatch → partial, same dual effect (merge instead of replace, and don't count the write as freshly-fetched) — renamed because "the response holds partial records" is what it always meant; "mismatch" described nothing about the actual behaviour.
  • BREAKING — fetchAgain → applyResponse (updateTarget only): trust the response as the record's new, full data. Same option, clearer name — it never re-fetched anything, the old name promised a request that doesn't happen. An empty response (undefined/null) keeps the optimistic patch as the record; the resource's lists are invalidated either way.
  • BREAKING — records are read-only. 4.x's itemDictionary and parentHasMany were writable refs, so writing into a record read back (getRecord(1).name = 'x') or into the dictionary changed the store. On useStructureRestApi they are now computed views over the cache: a write into getRecord(x) or itemDictionary is ignored with a Vue warning, and parentHasMany is rebuilt from the cache on every change, so an in-place edit of it is lost. Being computeds, a Pinia setup store treats them as getters, not state: records are no longer in $state, so $patch, $subscribe, SSR state serialization and persistence plugins no longer carry them. Migration: write through addRecord/editRecord/updateTarget, and link children with addToParent; persist or transfer records through the QueryClient (persistQueryClient, or TanStack's dehydrate/hydrate), not through Pinia state.
  • BREAKING — IToastType → EToastType (useNotificationsStore). The I prefix names an interface; an enum gets no such prefix. Update the import and every reference.
  • BREAKING — fetchMultiple resolves the requested ids: one slot per id, the fetched ones first, then the cached ones, undefined for an id the server left out. 4.x resolved every item the server returned (whatever its id), then the cached ones.
  • BREAKING — raw cache keys carry record and parent ids as strings: [resourceKey, 'target', dependsOn, String(id)], [resourceKey, 'parent', dependsOn, String(parentId), ...key]. 5 and '5' (a route param) now address one entry. A raw queryClient call that addresses a record or a parent list must use the string id.
  • BREAKING (types) — useStructureCrudApi's read operations take no options parameter: list: () => …, search: (filters, page, pageSize) => …, get: (id) => …. It was never forwarded to them; O now types only create/update/remove. updateTarget's apiCall is typed () => Promise<F> (4.x: () => Promise<F | (T | undefined)[]>); a response that is not a record object (empty, an array, a primitive) keeps the optimistic patch.
  • BREAKING — forced keeps its name and its intent (skip the cache, get fresh data), but its mechanism changed: it now runs the call with staleTime: 0 instead of dropping the cache entry first. A concurrent, non-forced call for the same thing joins this same request instead of racing it — dropping the entry first would have cancelled that other call with a rejection.
  • BREAKING — a search's apiCall (and fetchSearch/watchSearch/the CRUD search operation) now resolves { items, totalItems } instead of a bare array, and so do fetchSearch, the watcher's search(), searchNow() and resetFilters(). totalItems is stored alongside the page it came from, so it — and the derived pageTotal — now survive a cache hit, which closes the vanishing-pager bug above. fetchPage (unfiltered, via fetchPaginate) still resolves a bare array and discards totalItems.
  • BREAKING — The applied search. What pageItemList, pageTotal and totalItems show follows the applied search — a detached copy of the filters (plus its key) taken when a search is applied — not the live filters. Only search(), fetchSearch, and the CRUD searchNow()/resetFilters() apply the live filters (and watchSearch once at creation, with immediate); a page or page-size change fetches another page of the applied search without re-reading them. Fixes a real bug: binding a filter form's inputs straight to the live filters blanked the list while the user typed, before Search was pressed. With immediate: false, nothing is applied, so nothing is fetched, until the first search(): a page or page-size change before it does nothing, where 4.x fetched on any page change from creation. Migration: call search() (or searchNow()) wherever an edit of the filters should take effect, and once before relying on page changes of an immediate: false watcher.
  • BREAKING — on useStructureSearchApi/useStructureCrudApi, changing pageSize now also resets pageCurrent to 1, synchronously, in the same tick — a page number from the old page size rarely means anything under the new one. The reset watcher runs with flush: 'sync' specifically so it always lands before vue-query's own (pre-flush) key watcher can react to either change — otherwise, depending on where the watching call was started (a click handler, an onMounted after an await, a parent building a child's store), the key watcher could flush first and fire one wasted request for the OLD page at the new size before landing on page 1. ⚠ Setting pageCurrent to something else right after, in that same tick, now keeps that value instead of being forced back to 1 — useful for restoring ?page=4&size=25 from a URL. The client-side pagination of useStructureDataManagement/useStructureRestApi is unchanged.
  • BREAKING — every watcher returns a handle { stop, refetch, error } (IWatchHandle). watchTarget (and the CRUD watchOne) returned a bare stop function (WatchStopHandle), and watchSearch (the CRUD watchList) returned { stop, search }. Migration: const stop = watchTarget(...) → const { stop } = watchTarget(...). refetch() fetches now, joins a fetch already running instead of restarting it, and resolves with the watched data as cached after the fetch (a failure leaves the previous data); a watcher never rejects — failures show in error (and onError where offered). watchSearch adds search() (IWatchSearchHandle), which resolves undefined on failure; watchAny adds data.
  • BREAKING — useStructureCrudApi's operations parameter is required: it no longer defaults to {}. settings was already effectively required, since it carries resourceKey. Migration: useStructureCrudApi() → useStructureCrudApi({}, { resourceKey: '...' }).
  • BREAKING — a resource's data outlives the effect scope that built it. 4.x cleared the dictionary (and its private client) when the owning component or store was disposed; now stopping a scope ends the resource's cache subscriptions and its watchers, and every entry stays in the shared QueryClient. Call resetAll() first, or let a dependsOn change drop it, when data must leave memory.
  • BREAKING — watchTarget/watchSearch (and the CRUD watchOne/watchList) are now active: built on TanStack's useQuery, so an invalidateQueries call from anywhere, or a dependsOn change, re-runs them on their own — no imperative refetch needed. Their onSuccess/onError/onSettled follow: 4.x ran them once per id change, page change or search(); they now run once for every fetch of the watched query that lands, background ones included — after an invalidation (every create/update/delete refetches an active list or search), on window focus or reconnect once the data is stale, or when anything else fetches the same key. Migration: make the callbacks safe to repeat — never reset a form being edited from onSuccess, say — or compare against the last id/filters handled; queryOptions: { refetchOnWindowFocus: false, refetchOnReconnect: false } drops the focus/reconnect ones.
  • watchOne(idSource, settings?) forwards settings (forced, merge, staleTime, and the onSuccess/onError/onSettled callbacks) to watchTarget.
  • BREAKING — loading stays on every resource, now derived from TanStack: true while any query or mutation under the resource's resourceKey is in flight (the same as isLoading()) — every instance sharing that resourceKey, and the background refetches of active watchers, included. 4.x counted this instance's own calls (or its loadingKey in the core store). Migration: a spinner that should follow only some calls tags them with key and reads isLoading(key).
  • resetAll() now drops every kind of a resource's entries under the current dependsOn (records, lists, searches) in one call, rather than only resetting the local dictionary and parent relations, and refetches what active watchers show: the views empty, then the watched data comes back. resetRecords() removes records only, and marks the lists stale (their ids may now point at nothing), so active list watchers refetch.
  • Cache lifetime: records, parent lists (getListByParent) and search pages (pageItemList) are never garbage-collected, as the 4.x dictionary and search index never were — a search fetched imperatively (fetchSearch, the CRUD searchNow() with no watchList()) keeps showing with no watcher. They leave through dependsOn, resetAll and maxRecords (records also through resetRecords and deletes). The all/page/any entries follow the client's gcTime (TanStack's default: 5 minutes in a browser once nothing observes them); nothing renders them directly, so one leaving only means the next call asks the server again.
  • BREAKING — maxRecords' default drops from 100_000 to 10_000 — a record costs more as a TanStack query than as a plain object (measured for 10 000 records: ~2.2 MB as plain objects, ~10.5 MB as queries, i.e. ~0.8–1 KB more per record), so the same backstop now sits at a tighter bound, and crossing it drops more (next entry). Migration: an app holding more than 10 000 records per scope (infinite scroll over itemList, a big lookup table) sets maxRecords explicitly — 100_000 keeps the 4.x bound.
  • Crossing maxRecords now drops every other entry of the current scope — lists and searches too, not only records — while the list fetch that crossed it keeps its own entry, resolving its items and caching its list. Only records not cached yet count towards the bound (4.x counted the whole incoming batch), so refetching a list, or a record, that is already cached never triggers the wipe. Queries still fetching, or something is actively watching, are spared too (see Fixed, below, for the latter). fetchMultiple enforces the bound as well as every list-shaped fetch; the mutations and addRecord/editRecord never do, since they write one record you already hold data for, not a batch of possibly-new ones.
  • parentHasMany/getRecordsByParent/getListByParent on useStructureRestApi read the cache: a parent's children are the union of its keyless entry and every fetchByParent(..., { key }) bucket for it, in first-seen order, without duplicates. addToParent writes the keyless entry; removeFromParent/removeDuplicateChildren edit every bucket of the parent. Edited entries are marked stale, so the next fetchByParent asks the server.
  • BREAKING — watchTarget(apiCall, idSource, settings?): apiCall first, idSource second — the same order fetchTarget and watchByParent already use. Swap the first two arguments at every call site: watchTarget(userId, get) → watchTarget(get, userId).
  • BREAKING — useCoreStore's setLoading(key, value) makes both parameters required, and getLoading(key) requires its key too. setLoading('x') used to quietly store false — a caller meaning to mark work as started, with the value typo'd away or refactored out, silently marked it finished instead. Pass the value (and the key) explicitly everywhere now.
  • BREAKING — updateTarget/deleteTarget's mutation key now carries the id as String(id), the same form a record's query key already uses, instead of the raw K value. Only visible to a hand-written isMutating/isLoading filter matching on the mutation key's id segment directly — match String(id) instead of id. This is what a future per-record isSaving(id) needs to filter reliably regardless of whether id was passed as a number or a string.
  • BREAKING — Type naming, per the options-vs-return split (VD4): IStructureRestApi (used to name the options) is now IStructureRestApiOptions; IStructureCrudSettings is now IStructureCrudApiOptions. IStructureRestApi, IStructureSearchApi and the new IStructureDataManagementApi now name each composable's return type instead (previously unnamed/inferred), matching IStructureCrudApi, which already did. All four return types are now hand-written, explicit interfaces rather than ReturnType<typeof ...> inference — this also means their .d.ts output never references this package's own internal/ modules or @vue/reactivity/@vue/shared directly (V4.2), which failed to resolve under a strict pnpm layout. Rename IStructureRestApi/IStructureCrudSettings imports to the new options names; code naming the old (absent) return types starts naming the new ones instead.
  • BREAKING — K's default type, on every structure composable, is now TIdOf<T> — the type of T['id'] when T has one, string | number otherwise — instead of keyof T (the union of field names). The default identifiers is 'id', so this is the type its value naturally has; keyof T never described what a caller actually got back from createIdentifier/getRecord. Code that relied on the old default (calling getRecord('id') with the literal field name, say) passes K explicitly now: useStructureRestApi<IUser, keyof IUser>(...).
  • BREAKING — useStructureCrudApi's createOne/updateOne/deleteOne take one settings object instead of a bare per-call options argument: createOne(data, { requestOptions, key? }), updateOne(id, data, { requestOptions, merge?, applyResponse?, key? }), deleteOne(id, { requestOptions, key? }). requestOptions is the old options, forwarded to the operation the same way — createOne(data, opts) → createOne(data, { requestOptions: opts }). createOne and updateOne also newly expose createTarget's dummyData and updateTarget's merge/applyResponse, previously unreachable from the CRUD layer; all three take key.
  • BREAKING — useStructureFormValidation's handleSubmit runs one submit at a time. 4.x ran every call, so a double click (or Enter plus a click) ran the handler twice — two POSTs — and the first to finish cleared isSubmitting while the other was still running. A handleSubmit called while another is still running now resolves false at once: no validation, no handler call, showFormErrors untouched. false now means "the handler did not run", validation failure or not. Migration: to submit again, wait for the running submit to settle (await it, or watch isSubmitting turn false); a flow that deliberately ran two handlers concurrently calls the second one directly instead of through handleSubmit.
  • BREAKING — package.json declares engines.node: ">=22"; 4.x declared no engine at all. Node 20 reached end-of-life on 2026-04-30. npm only warns on a mismatch, but Yarn 1 and engine-strict installs refuse it. Migration: run your build, SSR and tests on Node 22 or later.
  • BREAKING — every read apiCall — fetch*/watch*, fetchAny/watchSearch, and CRUD's list/search/get — receives an IFetchContext ({ signal }) as its last argument, where 4.x passed nothing (fetchTarget, keyless fetchAny, CRUD list/get/search) or only the id (watchTarget); fetchMultiple's receives (missingIds, context) (see Added). signal is a lazy getter: reading it is what asks TanStack to abort the underlying fetch once nothing needs it any more (an unmounted watcher, a mutation cancelling that same record's read). Forward it to fetch/axios for a request that is genuinely cancelled, not just an answer later discarded. An apiCall that ignores its arguments keeps working, but a function passed by name whose own optional parameter sits in that slot now receives the context instead of its default: fetchAny(api.stats) with stats(range = '7d') gets { signal } as range. Migration: wrap such a function in an arrow, fetchAny(() => api.stats()).
  • BREAKING — fetchSearch(apiCall, filters, page, pageSize) applies page and pageSize to the live pageCurrent/pageSize, so pageItemList/totalItems show the page it just fetched. 4.x only fetched and cached it, leaving the screen on whatever page was current before, until something else moved pageCurrent. pageSize defaults to the current one, so leaving it out keeps the size on screen. Migration: fetchSearch moves the screen to the page it fetches, so do not call it to prefetch a page the screen should not show yet.
  • BREAKING — applyServerErrors no longer drops form-level and unmapped server messages. Without onUnmapped, 4.x discarded them and returned false; they now go to formLevelErrors (which isValid includes, so it stays false until clearErrors/resetForm), showFormErrors turns on, and the call returns true. It also returns true after handing every message to onUnmapped, where 4.x returned false whenever nothing reached formErrors — the docs' own snippet then showed a second, generic message on top of the one onUnmapped already displayed. false now means only that the rejection carried nothing at all. Migration: render formLevelErrors (a banner above the form, say) or pass onUnmapped to display them yourself, and drop any fallback message shown on false after onUnmapped displayed one.
  • BREAKING (types) — useStructureDataManagement's parentHasMany/addToParent/ removeFromParent/removeDuplicateChildren type a child id as K, the id's own value type, where 4.x typed it as typeof identifier — the identifier FIELD NAME, always string, so a numeric id needed an as never cast. Code passing string ids to a numeric-id resource, or reading parentHasMany as string[], no longer compiles. Migration: pass child ids in the id's own type, drop the casts, and read parentHasMany as K[].

Added ​

  • queryOptions: a narrow, explicit pick of TanStack's own useQuery options (retry, retryDelay, refetchInterval, refetchOnWindowFocus, refetchOnReconnect). Set on the resource as a default for every watch* call it makes, or per watcher (watchTarget, watchAll, watchByParent, watchAny, watchSearch) to override that default for one call. queryFn/queryKey/gcTime stay the engine's, as always. Reaches only ACTIVE queries (watch*), not one-shot fetch* reads, which have no observer for these options to mean anything on.
  • isSaving(id): true while an updateTarget/deleteTarget mutation on record id is running — a per-row pending signal for a row's own spinner, distinct from loading/isLoading, which cover the whole resource. A plain function, like isLoading: call it inside a computed or a template. Deliberately excludes createTarget (no stable id of its own yet) and mutateAny (no record id at all).
  • suspense() on every watch* handle (watchTarget, watchAll, watchByParent, watchAny, watchSearch): wraps TanStack's own suspense(), resolving once the watched data is cached — fetching first if needed. For SSR: call it in onServerPrefetch, so the fetch happens during server rendering and the client hydrates from an already-populated cache instead of fetching again on mount. Like refetch(), never rejects — a failure shows in error. A watcher that is not currently enabled (no id yet, enabled: false) resolves right away with whatever is cached, instead of waiting for enabled to turn true — TanStack's own suspense() on a disabled query never resolves at all.
  • useStructureSearchApi's pageItemList keeps showing whatever was shown a moment ago while a page, size OR filters change is in flight, instead of dropping to [] until the new page lands (totalItems already did this). A new isPlaceholder: ComputedRef<boolean> is true exactly while pageItemList is showing that fallback, false once the real current page is cached (including an empty one) or on a genuinely empty first load. Cleared on a dependsOn change or a resetAll(), so one user's or one language's rows never flash onto another's screen.
  • ISearchFetchContext<F>: fetchSearch's apiCall now receives { filters, page, pageSize, signal } as its context — the search this call was asked for, FROZEN at the moment it started — instead of the plain { signal } read context. An apiCall typed on the old, plain context still compiles; it just ignores the new fields.
  • package.json metadata: description, keywords, homepage, bugs, repository (needed for npm provenance) and author. Previously all unset. engines.node is new too — see its own entry under Changed.
  • watchAll/watchByParent/watchAny accept enabled and a reactive key (a Ref, ComputedRef or getter for either). watchByParent also idles automatically — no fetch, apiCall never called — while its parentId is nullish, instead of calling apiCall(undefined).
  • useNotificationsStore.addMessage returns the new message's id, matching what the docs already promised as the argument to hideMessage/showMessage/removeMessage — previously a caller had to re-read history[history.length - 1].id to get it.
  • fetchMultiple's apiCall now receives the ids it actually needs to fetch — (missingIds, context) => …, exactly checkMultiple(ids).expiredIds — instead of no arguments at all. Building GET /users?ids=1,2 from missingIds means an id already fresh in the cache is never re-requested; previously this required calling checkMultiple yourself first.
  • dependsOn — a getter returning the values a resource's data depends on (whose data, which language): dependsOn: () => [session.viewer?.id ?? 'guest', locale.value]. Read fresh at the start of every call; when it changes, the resource's entries under the old value — records included — are cancelled and removed (not merely marked stale), every active watch* re-runs under the new one, and an answer or rollback that settles afterwards is dropped. Closes the cross-user and cross-language data leaks above.
  • watchAll, watchByParent, watchAny — the active counterparts of fetchAll/ fetchByParent/fetchAny, so a view never has to fetch imperatively from onMounted or a click handler. watchAll/watchByParent take the full IFetchSettings (forced: every mount or key switch asks the server); watchByParent's parentId may be a plain value, a Ref or a getter, and re-runs when it changes, and its apiCall receives the parent id the running query is for. watchAny(apiCall, { key, forced?, staleTime? }) requires key (an active query needs a stable identity) and also returns data, since its answer isn't a record.
  • mutateAny(apiCall, { key? }) — a command that doesn't fit createTarget/updateTarget/ deleteTarget's record shape, run as a one-shot TanStack mutation. Does not auto-invalidate anything — it has no defined relationship to any record or list.
  • isLoading(key?) on every resource — a plain function, true while a query or mutation of the resource runs whose key starts with the given segments (isLoading(['dash']) covers a call keyed ['dash', 'w1']); no key: anything of the resource. Call it inside a computed.
  • useIsLoading(prefixes?, queryClient?) — a new standalone composable: "is any of these resources busy" across the whole app, matching by resourceKey string prefix (the same rule useCoreStore's isLoading already uses). Built on useIsFetching/useIsMutating; the optional queryClient is for code with no injection context.
  • totalItems on useStructureSearchApi/useStructureCrudApi — the applied search's server-reported total, for "124 orders"; pageTotal is now derived from it instead of a local item count. While a new page loads it keeps the applied search's latest known total.
  • searchGet(filters, page, pageSize, { key? }) — an optional fourth argument reads a page cached under a key bucket.
  • resourceKey and editRecords on the object useStructureRestApi returns.
  • Exported types: IWatchHandle, IWatchCallbacks, IWatchTargetSettings, IWatchSearchHandle, ISearchResult, IUpdateTargetSettings, IRecordStore, IFetchContext, TIdOf, ICreateOneSettings, IUpdateOneSettings, IDeleteOneSettings, IWatchListSettings, IWatchAnySettings, TListCall, TMultipleCall, IStructureRestApi (now the return type), IStructureSearchApi, IStructureDataManagementApi.
  • useStructureDataManagement gains an optional, advanced third parameter, recordStore: the write surface its CRUD methods go through instead of owning a plain ref directly. Defaults to a local, in-memory store (so the composable is unaffected on its own) — useStructureRestApi supplies a TanStack-backed one, which is what turns itemDictionary into a live, read-only view of the query cache rather than a second copy of it.
  • Documentation page for useIsLoading, and diagrams of the cache layout, the dependsOn flow and the applied search.

Removed ​

  • BREAKING — destroy(), startLoading()/stopLoading(), saveRecords, the getLoading/ setLoading options, the per-call loading and loadingKey settings, fetchLike, searchCleanup()/resetSearches()/searchCached and the ISearchCache type.
    • destroy(): there is no private client left to tear down; stopping the owning scope ends the subscriptions and watchers, and resetAll() removes the data.
    • Loading: loading itself stays (see Changed); what is gone is the manual ref-count and its wiring. TanStack already knows what is in flight, so there is nothing to connect an external store to (getLoading/setLoading), and no per-call opt-out (loading: false) or per-call loading name (loadingKey): tag a call with key and ask isLoading(key).
    • fetchLike: storing the record "as fetched" is now what createTarget always does, and what updateTarget does with its response; updateTarget opts out with applyResponse: false.
    • The search page→ids index: a search's pages are cache entries now, read directly; there is no separate index to clean up or cap.
    • searchKeyGen, from useStructureSearchApi's returned object: internal machinery (a thin wrapper over the same canonicalization every cache key in the toolkit already uses), not something a caller ever needs — searchGet/checkSearch already accept the filters object directly. If you were building a pre-serialised key by hand, pass the filters object instead.

Fixed ​

  • useStructureFormValidation's initialFormData/serverErrors refs were ref<T>(...)/ ref<Partial<Record<keyof T, string[]>>>(...) with no cast: under vue's declared floor (^3.4, tested via the peer-floor CI job now also running test:types — V4.5), Ref<T>'s older, single-parameter form routes every .value through UnwrapRef<T>, which cannot resolve for a generic T involving any — a build error, not a runtime bug, but a real one for anyone actually on the declared floor. Cast past UnwrapRef, matching form/formErrors's existing casts.
  • IStructureFormValidation<T> was ReturnType<typeof useStructureFormValidation<T>>: because T can involve any (the record constraint every structure composable uses), Vue's own ref inference could not fully resolve, and the published .d.ts fell back to import("@vue/shared") and import("@vue/reactivity") type references — packages this package does not depend on directly, which do not resolve under a strict (pnpm-style) node_modules layout. Replaced with an explicit, hand-written interface (the same fix V2.8 already applied to REST/Search/ DataManagement/CRUD), with no shape change. tests/package/dtsGuard.mjs (added in V4.1) now also scans every public .d.ts for @vue/reactivity/@vue/shared and this package's own internal/ modules, so a future leak fails the build instead of shipping.
  • useStructureFormValidation's schema option was typed with Zod's own ZodType<T>, imported from zod — an OPTIONAL peer. The published .d.ts carried that import, which breaks type-checking for any app that has not installed zod, whether or not it uses this composable. schema is now typed with a new, exported IValidationSchema<T>: Zod's safeParse contract described structurally, so this package's types never reference zod. Any real Zod schema satisfies it as-is. A new tests/package/dtsGuard.mjs, run as part of test:package, fails the build if a published .d.ts ever references zod again.
  • useStructureSearchApi's knownTotal used Array.prototype.toSorted, which needs Safari 16 (September 2022) — unusably recent for a library with no stated browser floor. Replaced with a single-pass max over dataUpdatedAt, with no behaviour change: both picked the single most recently cached page's total.
  • useStructureDataManagement always built its own local parentHasMany state (a plain ref and its editors) even under useStructureRestApi, where that state was immediately shadowed by a second, TanStack-backed implementation of the same hasMany/belongsTo surface built separately in restResource.ts — dead reactive state allocated, and silently discarded, on every REST-mode resource. Relations now go through an IRelationStore seam, the same pattern IRecordStore already uses for records: a local dictionary by default, and the TanStack-backed one useStructureRestApi passes, so nothing local is built when the REST store supplies it.
  • useStructureDataManagement's lastInsertedIdentifier/lastInsertedIdentifiers are documented as tracking newly CREATED records, but addRecord/editRecord moved them on every write, including one storing a record the server merely reported (a list fetch, a GET by id) — useStructureRestApi's fetchTarget/fetchAll/watch* overwrote them on every call, with nothing about an actual create. IRecordStore gains an optional isFetching(); addRecord/ editRecord skip the tracking side effect when it reports true. useStructureRestApi's createTarget (a real create, also stored through the same asFetched-wrapped write for its own freshness reasons) marks it explicitly instead of relying on that side effect.
  • useUploadProgress's track shared its progress ref across overlapping calls with no ordering guard: whichever call settled first reset the bar to idle even while a second call was still in flight, and a stale call's late progress event could overwrite the newer call's percentage. A per-call token now scopes both the reporter and the idle-reset to the call that started it, so only the newest track call can move progress.
  • useStructureFormValidation's root-level Zod issues (an empty path — a cross-field .refine() with no path option, say) were silently dropped: validate() could return false while isValid stayed true and nothing showed on screen. A new formLevelErrors: Ref<string[]> now holds them, and isValid includes it (applyServerErrors fills it too — see Changed). clearErrors/resetForm clear formLevelErrors along with formErrors.
  • useStructureFormValidation's revalidateOn re-ran the public validate(), which replaces formErrors/formLevelErrors wholesale from a fresh schema parse — wiping out any error set by setFieldError/applyServerErrors (a "this email is already taken" from the server, say) the moment a watched source changed, even though the schema itself has nothing to say about whether that error still applies. revalidateOn now merges the refreshed schema result UNDER those server-set errors instead of replacing them. An explicit validate() call still replaces everything, since a submit is a fresh attempt the server will answer again.
  • useStructureFormValidation's baseline (initialFormData) and form were shallow copies: setInitialData, resetForm, setForm and the initial state all spread one level deep only, so a nested field stayed the SAME object across initialData, the baseline and form. A hydrated readonly() record (selectedRecord, say) then left that nested object genuinely read-only inside form too — writing to it failed silently (Vue's own dev warning) instead of editing the form. resetForm also handed the live form and the baseline the same nested object, so editing one edited the other. All four now use a detached (deep) copy, sharing no plain object or array with the source (Set, Map and Date fields also detached now — see Fixed, below).
  • watchByParent called apiCall(undefined) when its parentId was nullish, instead of idling: a screen whose parent id starts out unknown (still loading from a route param, say) fired a request for undefined on mount. It now idles — no fetch, apiCall never called — exactly like watchTarget already does for a nullish id, until parentId becomes a real value.
  • A resource built outside an effect scope (a component's setup(), a Pinia setup store, an effectScope()) had its cache subscriptions leak silently for the QueryClient's whole lifetime, with no way to stop them and nothing telling you it happened. It now emits a console.warn naming the resourceKey, once per key.
  • asFetched restored its internal "this write is a server answer" flag without a finally: a throw inside the writes it wraps left the flag stuck on, so every later local write (an optimistic edit, a plain addRecord) was wrongly stamped fresh instead of keeping its previous freshness. Wrapped in try/finally now.
  • editRecord read through dictionary on every call, which under the REST layer is a computed that rebuilds its entire scope from the query cache whenever it is stale — and each write invalidates it for the next read. Merging or partially writing a batch (merge/partial settings on a list fetch) called editRecord once per item, so a batch of N items over a cache of M records did O(N × M) work: 1.7s for 1k items over 3k cached, measured before this fix. IRecordStore gains an optional read(id) — an O(1) lookup both built-in stores implement — and editRecord reads through it instead, bringing the same batch back to milliseconds.
  • A maxRecords wipe could empty a record watchTarget was actively showing on screen, with nothing telling it to refetch — the crossing batch kept its own entry, but any other cached record, watched or not, was dropped the same way. A record with an active observer is no longer evicted by the wipe (it still counts toward the bound, it is just never the one removed). Single- record fetches (fetchTarget/watchTarget) also never enforced the bound at all: browsing many detail pages one at a time, each cached with gcTime: Infinity, grew the cache without limit; they now enforce it exactly like a list-shaped fetch does, for a genuinely new id.
  • A resource's start-up sweep dropped every cache entry of its resourceKey under any other dependsOn value, and a dependsOn switch dropped everything under the old value the same way — both on the assumption that nothing still alive could be using it. True for an ordinary scope change, false the moment a second instance of the same resourceKey is alive under a different scope: creating it wiped the first instance's data, and either instance moving its dependsOn away from a scope the other was still showing dropped that scope's data out from under it. A new live-scope registry (src/internal/scopeRegistry.ts) tracks how many instances currently claim each scope; the sweep and the switch's drop now only remove a scope nothing claims any more, so two instances of the same resource under different scopes coexist.
  • fetchTarget/watchTarget by an alternate key stored the record twice: fetchTarget(apiCall, 'my-slug') resolving { id: 7 } wrote the record under 7 (its own id) and, separately, under 'my-slug' (whatever TanStack writes the query function's return value back under) — two independent copies that would silently diverge the moment either one changed. The requested key now holds an alias entry instead (pointing at 7, never a copy of the record); getRecord, selectedRecord and everything that reads through getRecord follow it one hop, and itemDictionary/itemList only ever see the record once.
  • watchTarget cleared selectedIdentifier on any failed fetch, including a background refetch of a record already on screen: a transient network error blanked the detail view even though the previously fetched record was still sitting right there in the cache. The selection is now cleared only when it still points at the failed id and nothing is cached for it any more — a genuine "there is nothing left to show" case, not "the latest refetch happened to fail".
  • A watcher's onSuccess/onError/onSettled ran synchronously inside TanStack's own query-cache notify dispatch. A callback that threw there corrupted that dispatch mid-flight — TanStack read it as the fetch itself failing, putting the query into an error state, firing onError for a fetch that had actually succeeded, and (on watchTarget) clearing the selection. The callbacks now run a microtask after the dispatch that triggered them has fully finished, so a throw inside one surfaces as an ordinary uncaught error instead.
  • TanStack's hydrate() — what persistQueryClient uses to restore a cache persisted on a previous visit — added a restored query straight into the cache already holding data, without ever going through .setData(), so only an added cache event fired, never updated/success. The record view (itemDictionary, itemList, getRecord, …) is a computed keyed off a data counter that only moves on a counted cache event; an uncounted added left it stuck on its old (usually empty) value until something unrelated to the restore changed the same kind. An added event with data now counts too.
  • updateTarget/deleteTarget snapshotted the record to roll back to before cancelling its in-flight read, not right before applying the optimistic edit: two mutations on the same id started in the same tick both captured the same "before either ran" snapshot. If the older one succeeded and the newer one then failed, the newer one's rollback restored that shared snapshot — undoing the older one's already-confirmed change, not just its own — and left the record stamped as fresh with no way to tell it was wrong. The snapshot is now taken right before applying, so a same-tick sibling's rollback returns to what the other call itself left behind; and a rollback, or a success skipped because a newer mutation now owns the record, invalidates it, so an active watcher reconciles it with the server instead of trusting the local guess.
  • updateTarget/deleteTarget cancelled every list read of the resource's current scope (all, parent, page, search), not just the record's own: an unrelated in-flight fetchAll (or a search page, or another parent's list) resolved [] on that cancellation and never retried on its own. Reads were also unprotected the other way — fetchTarget's id-less path could resurrect a record a deleteTarget had just removed, and fetchMultiple could overwrite a value an updateTarget had just applied, whichever answer happened to arrive last. A mutation now cancels only the record's own query; a per-resource write guard tracks which id a mutation owns and for how long, and every read's write to a record — fetch*/watch* and search alike — is dropped once a mutation on that id has started since the read began, or is still running.
  • useStructureDataManagement/useStructureRestApi's composite identifiers (identifiers: ['a', 'b']) collided whenever a value contained the delimiter: { a: 'x|y', b: 'z' } and { a: 'x', b: 'y|z' } both joined to 'x|y|z', so adding the second silently overwrote the first. createIdentifier, getRecord's multi-argument form and editRecord's array id now escape each value before joining: every character of the delimiter, and the escape character itself, gets the escape character in front. That holds for any delimiter, multi-character and self-overlapping ones ('::', '--') included. The escape character is a backslash unless the delimiter contains one, then the next character code the delimiter does not use. Different tuples never produce the same id; an id whose values use none of those characters is unchanged.
  • updateTarget's success handler applied the server response unconditionally once its scope was still current, with no check for a newer mutation on the same id: a deleteTarget removing a record while an older updateTarget for it was still in flight could be undone by that update's late, stale success — resurrecting a record the app had just deleted. Success now applies only while the record still holds exactly what this call's own optimistic edit left it as, the same rule the rollback path already followed for a failure.
  • createTarget left the dummyData placeholder on screen when the API resolved empty; it is now removed whatever the call resolved, and the lists are marked stale on any success.
  • A failed fetchOne cleared the selection even when a newer fetchOne had selected another id meanwhile; it now clears it only while it is still the failed id.
  • useStructureCrudApi's resetFilters() restored whatever had been typed instead of the initial filters: ref(initialFilters) wrapped the caller's object, so a form editing filters in place rewrote initialFilters too. filters is now a detached copy that never shares objects with initialFilters.
  • removeFromParent on a parent with no recorded children threw a TypeError; it now leaves that parent with an empty list.
  • useStructureDataManagement's getRecordsByParent/getListByParent returned nothing for a parent id of 0 or ''; those are real parent ids now, and only an omitted (undefined) one means "no parent".
  • docs/guide/getting-started.md claimed @tanstack/query-core/vue-query were regular dependencies landing in your bundle automatically; neither was ever true (the former was a peer, the latter wasn't referenced at all). Corrected alongside the peer-dependency change above.
  • dist's .js.map/.d.ts.map files pointed at src/, which the package never ships; a debugger stepping into the library, or a bundler resolving a source map, hit a 404. sourceMap and declarationMap are off; dist's .d.ts files still carry full types, just no map.
  • Under Vue 3.3, changing pageSize on useStructureSearchApi/useStructureCrudApi could refetch the old page number at the new page size before resetting to page 1, firing one extra request: Vue 3.3's watcher scheduler could flush the active query's key before the pageSize→pageCurrent reset watcher runs. vue peer floor moves to ^3.4, which orders them correctly.
  • dist couldn't be loaded by plain Node or by moduleResolution: node16/nodenext: tsc emitted extensionless relative imports ('./stores/core'), which Node's own ESM loader refuses (ERR_MODULE_NOT_FOUND). A bundler-free consumer — SSR that externalizes the package, some test runners — hit this on every import. src/ now imports its own modules with a .js extension, and tsconfig.json builds with module/moduleResolution: NodeNext, so the compiler refuses a missing one from now on.
  • useStructureDataManagement's pageSize accepted any written value, including 0 or negative — pageTotal (Math.ceil(totalItems / pageSize)) then read as Infinity, while NaN made it NaN, a fraction a fractional page, and Infinity an empty first page. pageSize is now a customRef that keeps it a whole number of at least 1: a write is rounded down and clamped to 1, and a non-finite write (NaN, Infinity) is ignored, keeping the current size.
  • editRecord checked id with !id, so a falsy-but-real id (0, '') was treated as "no id given" and silently fell back to inferring the id from data instead of editing the record it was actually asked for. Checks id === undefined instead — 0 and '' are real ids.
  • useAsyncAction's run, useUploadProgress's track and useLivenessProbe's check all called the caller's function directly, before any promise existed, so a SYNCHRONOUS throw (a throwing buildOptions, a null dereference before the first await) escaped instead of settling into the composable's own state: run() left loading stuck true with error unset, track() left isUploading stuck at 0%, and useLivenessProbe threw out of its own creation. A new internal promiseTry (new Promise((resolve) => resolve(fn())), the same contract as ES2025 Promise.try) now wraps all three call sites, so a synchronous throw settles exactly like a rejection.
  • useLivenessProbe's check(), called after stop() (a late "Retry" click on an unmounted banner), still probed and, on failure, scheduled a retry timer nothing would ever cancel — every retry scheduling the next, forever. stop() now sets a stopped flag check() checks first: after stop(), check() resolves without probing, and no timer is ever scheduled.
  • A one-shot fetch* read that failed on a key an active watch* was showing reset that query in place — the same handling every other removal gives an observed entry — which wiped the watcher's own error back to null along with it. The empty, error-only entry a failed one-shot read leaves behind is now removed only while nothing watches it; a watched one is left untouched, so the watcher keeps showing the error and retries it on its own.
  • useStructureCrudApi's searchNow/resetFilters handed fetchSearch a closure that read live filters/pageSize when IT RAN, not when searchNow/resetFilters was called. TanStack keeps the last apiCall/queryFn a query was given and re-runs that same closure on an unrelated invalidation (invalidateQueries, another mutation's own invalidation) — not a fresh call. So a deleteOne elsewhere could re-run resetFilters's old closure with whatever was typed in the filter form by then (not submitted), and the answer landed under the search that was actually applied. Both now read the search from fetchSearch's new context (see ISearchFetchContext under Added) — a snapshot frozen when the call started — so a later re-run asks the same question again.
  • useStructureSearchApi's placeholder fallback (pageItemList/isPlaceholder, see Added) only looked among pages of the currently APPLIED filters. Applying filters that had never been fetched before — genuinely new ones, not merely a page or size change — dropped the list to [] with isPlaceholder: false: exactly the combination the docs reserve for "genuinely empty," so an empty-state message flashed on every new search, contrary to the feature's own stated intent ("page/size/filters change"). It now falls back to whatever was shown a moment ago regardless of what changed, the same idea as TanStack's own keepPreviousData.
  • detachedCopy (and so useStructureFormValidation's form/setForm/the baseline) rebuilt only plain objects and arrays; a Set or Map field was kept by reference. Vue proxies a Set or Map like any other object, so form.tags.add(x) (a tag picker) was a real, reactive edit that reached the source record AND the reset baseline — resetForm() kept it. isDirty's JSON.stringify compounded this: JSON.stringify(new Set(['x'])) is "{}" for any Set, so it couldn't see the edit either way. detachedCopy now rebuilds Set/Map (values recursively; a Map's keys stay by reference, so a Map keyed by object identity still resolves .get()) and Date too; isDirty compares by stableKey instead of JSON.stringify, which also makes property order a non- difference. Class instances are still kept by reference — there is no safe generic clone for one — documented as policy, not a gap.
  • A late answer's "is this still mine to store?" check judged by the FETCHING INSTANCE's own current dependsOn(), not by whether anything still showed the scope the fetch actually ran under. Two instances of one resourceKey sharing a client under different scopes is documented as supported (dependsOn, "two shops compared side by side"); instance A moving its dependsOn elsewhere while its own fetch for the old scope was still in flight made that fetch's answer "late" by A's own reckoning, even while instance B still showed that exact scope — the rows were dropped, but { ids: [] } was cached as fresh under it, so B's next read silently resolved empty instead of asking the server. fetchSearch had the same problem the other way: cut short by a dependsOn change, it rebuilt its page key to read the total AFTER the change, pairing its (correctly empty) answer with the NEW scope's total instead of 0. An answer now belongs to the scope baked into its own running query's key (resourceKeys.ts's scopeOf) — fixed for that query's whole life — and is storable as long as ANY instance still claims that scope (the existing live-scope registry, scopeRegistry.isLive), not just the fetching instance's own. fetchSearch now builds its page key once and reads the total off that same key.
  • Only getRecord followed a record fetched by an alternate key (fetchTarget(apiCall, 'my-slug') resolving { id: 7 }) to the record it pointed at — every WRITE used the id as given instead: updateTarget(..., 'my-slug') created a second, id-less record; a failed update by slug deleted the pointer on rollback; deleteTarget(..., 'my-slug') removed only the pointer, leaving record 7 in itemList; invalidateRecord(7) never reached a screen watching 'my-slug'; and once record 7 was actually removed, the 'my-slug' pointer stayed cached and fresh, serving undefined without ever asking the server again. Every write and removal — editRecord, deleteRecord, updateTarget, deleteTarget, invalidation — now follows the pointer to the record it names, and reaches every OTHER pointer to that same record too, through one place (queryRecordStore.ts's resolve/keyOf, resourceKeys.ts's new refersTo predicate). A server-returned record — a read, an update response, or a create response — is stored under one rule (storeServerRecord): always under its own id, with the address it was requested by, if different, becoming a pointer instead of a second, divergent copy. isSaving(id) and updateTarget/deleteTarget's own mutation key now resolve id first too, so isSaving('my- slug') and isSaving(7) agree. Known limit, documented: an address never fetched before cannot be resolved ahead of the server's answer — the very first updateTarget(..., 'my-slug') with no prior fetch by that slug applies its optimistic patch under 'my-slug' itself; the update's own response then resolves it into a proper pointer.
  • The "a read never overwrites a record a newer mutation has touched" guarantee had three holes, all traced to src/internal/writeGuard.ts: a private, per-useStructureRestApi-INSTANCE map no other instance of the same resource (sharing the same client) could see; keyed by the id AS GIVEN ('1' from a route param, 1 from createIdentifier, treated as different records); and bypassed outright by targetQueryFunction, which — when the guard blocked its write — still RESOLVED with the raw fetched item, and TanStack wrote that return value back under the record's key regardless, so a read landing while a delete was in flight could bring the deleted record back. A related gap (R10): with applyResponse: false or an ack-only response, a read landing mid-update re-stamped the still-unconfirmed optimistic patch as freshly fetched. writeGuard.ts is deleted outright. "Is record id being changed?" is now asked of TanStack's own MutationCache (src/internal/recordMutations.ts) — the exact question isSaving already asks — so every instance sharing a client sees every mutation, ids are always compared as strings, and there is nothing left to prune by hand (a settled mutation leaves the cache on its own gcTime, same as everything else). updateTarget/deleteTarget's optimistic protocol now runs directly on TanStack's own mutation lifecycle (onMutate/onSuccess/onError/ onSettled) instead of a hand-chained .then(): TanStack marks a mutation pending — visible to every read's own check — before onMutate (which cancels the record's read, snapshots and applies the change) even starts, closing the window where a read could begin unaware of it. A blocked read now cancels its OWN query instead of resolving with its stale answer; TanStack then discards whatever it would have written and reverts to what is already cached, with that entry's ORIGINAL timestamp — fixing both the resurrection and R10 in the same change. The public API, and the documented mutation key layout ([resourceKey, 'update' | 'delete', id]), are unchanged.
  • A maxRecords wipe spared a query with its OWN active observer, but not what that query merely REFERENCED: a watched list's own entry survived, but its rows are separate target entries with no observer of their own, so the wipe dropped them anyway — the list stayed cached and fresh, pointing at ids that resolved to nothing, with nothing telling it to refetch. A watched alternate key (watchTarget(get, () => 'my-slug')) had the same problem the other way: the pointer entry survived (it has the observer), the record it pointed at did not. enforceMaxRecords now collects what is on screen straight from the cache before it runs — a watched list's own ids, and a watched alias's aliasOf — and spares those records too, on top of directly-watched ones.
  • stableKey (used for every internal cache key: search filters, dependsOn snapshots) turned any Set or Map into {} for the same reason (canonicalize walks a value through Object.keys(), which is [] for both). Two searches whose filters held different Sets shared ONE cache entry. stableKey now expands a Set/Map into a tagged, order-independent shape first (sorted by each item's own stableKey, so insertion order is never a difference, and a Set never collides with a plain array of the same items) — a local pre-pass ahead of @guebbit/js-toolkit's own canonicalize handling this natively.
  • watchTarget with no id and watchByParent with no parent id sat on the placeholder key [resourceKey, 'any', dependsOn, 'idle'] — exactly the key fetchAny/watchAny build for key: ['idle'], so such a fetch settled the idle watcher (onSuccess with nothing to show). The placeholder is now a kind of its own, [resourceKey, 'idle', dependsOn], which no caller key can reach.
  • watchByParent's refetch() with a nullish parent id called apiCall with that nullish id: TanStack's refetch() runs a query even while it is disabled. It now resolves [] without fetching, like watchTarget's already did with no id. A watcher disabled through its own enabled: false still fetches on refetch(), as TanStack's on-demand pattern expects.
  • queryOptions (on the resource, or on a watcher) was spread into useQuery as-is, so any key it carried beyond the five documented ones — from a JS caller, or a widened object — overrode the engine's own setting: gcTime: 0 dropped a watched record the moment its watcher stopped. Only retry, retryDelay, refetchInterval, refetchOnWindowFocus and refetchOnReconnect are passed on now; anything else is ignored.
  • watchSearch(apiCall, { forced: true })'s search() counted a cached page as fresh anyway: search()'s own forced argument defaults to false, and that default replaced the watcher's setting. The argument now only adds forcing to one search; it never lifts the watcher's.
  • useStructureDataManagement's default (local) relation store compared child ids with ===, while the REST store compares them as strings: removeFromParent('p', '1') (a route param) never unlinked a linked 1, and addToParent linked the same child again on every call, though IRelationStore promises a no-op for a child already linked. Both stores now compare child ids the way the record dictionary keys them — 1 and '1' are the same child, a symbol only matches itself — for linking, unlinking and removeDuplicateChildren alike.
  • useStructureFormValidation read its error records through the prototype chain: a field named like an Object.prototype member (constructor, toString, valueOf) looked present before anything was stored under it. validate() threw on a failing field named constructor, and applyServerErrors threw on a server message for any such name — for a field the form does not have (which belongs in formLevelErrors) as well as for one it does. Both now read own keys only.
  • package.json's license field was "AGPLv3.0", which is not a valid SPDX expression: npm warns on it and license scanners cannot identify it. It is now "AGPL-3.0-only", matching the bundled LICENSE.

4.0.0 ​

Changed ​

  • BREAKING — useCoreStore's isLoading is a function taking optional key prefixes, not a computed. isLoading() still answers "is anything loading at all"; isLoading(['account', 'cart']) answers only for the keys starting with one of those. The old computed collapsed an already-granular dictionary back to one bit before any UI saw it, so a background poll lit the same indicator as a user's save. Replace core.isLoading with core.isLoading(), and note it no longer comes out of storeToRefs — destructure it from the store and call it inside a computed.
  • loadings is typed Record<string, boolean>: Object.entries never returned the symbol half, so a symbol key could be set and then never matched by anything reading the dictionary.

3.1.0 ​

Added ​

  • Added useAsyncAction: loading/data/error state for ONE async call, and the wrapper that drives it. The counterpart to the useStructure* family rather than a competitor — those are about records (identified, cached, mutated), this is about a one-shot call whose answer is just a payload. fetchAny already covered the loading half but exposes neither data nor error, which is what left a dashboard writing the same three-ref block once per endpoint. It never rejects: a failure lands in error and the promise still resolves, so one dead endpoint renders one error panel instead of blanking the page. That makes it a composable for READS — a write whose outcome the user asked for should reject and let the view answer with the toast it already writes. Only the newest run may write, so a double-clicked Refresh cannot overwrite a fresh answer with a stale one, nor clear loading while its successor is still in flight.
  • Added useLivenessProbe: a connectivity banner's whole state in one boolean. Probes once on creation, again on every browser online event, then retries slowly and ONLY while down — a reachable target is never polled twice. The retry chain has exactly one owner, which is the failure this exists to prevent: two online events while down otherwise leave two independent loops running, each spawning its own successor forever, with teardown able to cancel only the last one scheduled. What is being probed is the caller's business — anything that rejects when unreachable will do. Stops with the surrounding effect scope; stop() is idempotent and a probe that outlives teardown can no longer write to down.
  • Exported IAsyncAction, IAsyncActionSettings, TErrorResolver, ILivenessProbe and ILivenessProbeSettings.
  • Added documentation pages for useAsyncAction and useLivenessProbe.

Changed ​

  • BREAKING for anyone pinning it — @guebbit/js-toolkit moved from ^1.2.1 to ^2.2.0. useAsyncAction's default error resolver is its new extractErrorMessage, which is what makes a plain-object rejection work: an interceptor that normalises failures rejects with an object literal rather than an Error, and anything testing instanceof Error reads a real API refusal as "nothing usable". Nothing else in the package changed behaviour.

3.0.0 ​

Removed ​

  • BREAKING — removed dialogs from useNotificationsStore. It was a bare Record<string, boolean> with no actions of its own, so it carried no behaviour worth sharing: dialog visibility is an application concern and belongs in a consumer's own store. Replace notifications.dialogs[name] with a local ref({} as Record<string, boolean>).

Added ​

  • Added useStructureCrudApi, the highest-level entry point: a whole resource — list, filtered search, read, create, update, delete — declared as the API calls that reach it. It is useStructureSearchApi with the wiring done, and spreads everything that composable returns, so it is a convenience and never a ceiling. Every operation (list, search, get, create, update, remove) is optional; a method whose operation was not supplied returns a rejection naming it. Owns the filters ref, so the filters driving the search and the filters scoping pageItemList cannot be two different objects. optimisticPatch covers the case where the update payload is not a partial record — a multipart form whose payload carries a File the record has no business holding.
  • Added useUploadProgress: reactive progress/isUploading for one upload, plus a track wrapper that returns to idle however the request ends. HTTP-client-agnostic — the client-specific part is a single buildOptions function passed in, so the toolkit never names axios. track(send, { enabled: false }) calls send with no options at all, for the form whose file is optional.
  • useStructureFormValidation now owns showFormErrors across the whole submit flow: handleSubmit reveals on a validation failure, hides on success, and leaves it off when the handler itself throws (an API failure is not a statement about any field).
  • Added revealErrors() to useStructureFormValidation: turns showFormErrors on, waits for the render, focuses the first invalid field, then calls onInvalid. The wait is why it is a method — fields only acquire their invalid markers once the flag has propagated, so focusing any earlier finds nothing.
  • Added applyServerErrors(error, options?) to useStructureFormValidation: attaches the errors an API rejected a submit with to the fields they belong to. Finds them at the root, under .data or under .response.data, and understands field maps, { field, message } lists (including name/param/path and msg spellings) and bare string lists. Merges rather than replaces, and returns false when the rejection carried nothing displayable, so the caller knows it still owes the user a message.
  • Added formElement, invalidFieldSelector and onInvalid options to useStructureFormValidation. Focus targets [aria-invalid="true"] by default; omitting formElement makes revealErrors a pure state change with no DOM access, for SSR and node tests.
  • Exported IStructureFormValidation, IStructureCrudApi and IUploadProgress return-type aliases, plus DEFAULT_INVALID_FIELD_SELECTOR.

Changed ​

  • Documented that watchTarget selects the record it fetches while fetchTarget does not, and why: watchTarget is bound to a screen's id, whereas a prefetch or background refresh through fetchTarget must not steal the selection from what the user is looking at. useStructureCrudApi's fetchOne is the selecting variant.
  • Added documentation pages for useStructureSearchApi (previously undocumented despite being a core composable), useStructureCrudApi and useUploadProgress.
  • handleSubmit is written as a promise chain rather than async/await with try/finally, matching the style of the rest of the package. Behaviour is unchanged: the Promise.resolve() the chain starts from is what keeps a synchronous throw inside the submit handler arriving at the caller as a rejection, exactly as await did. src/ no longer contains async/await anywhere.

2.2.0 ​

Added ​

  • useStructureFormValidation takes a third options argument with a revalidateOn option: a WatchSource (or array of them) that re-runs validate() over the unchanged form data whenever it changes. It exists for language switches — formErrors holds resolved strings, so an error already on screen cannot re-translate itself no matter how the schema is shaped, and only re-parsing produces the new wording. It fires only while errors are on display, so a pristine form is not validated just because the language changed. Deliberately generic: the toolkit stays i18n-agnostic and the same hook covers any other reason to re-validate.

Changed ​

  • Documented that a schema whose messages are thunks (error: () => t('…')) is resolved just as late as a getter schema, and is the safer of the two: toValue(schema) runs inside validate() and nowhere else, while a getter that is accidentally invoked at the call site type-checks and silently freezes the language.

2.1.3 ​

Fixed ​

  • createIdentifier (in useStructureDataManagement/useStructureRestApi) now honours a custom single identifier passed as its second argument. Previously the custom value was ignored for non-array identifiers and the default identifier was used instead, so createIdentifier(item, 'slug') returned item.id rather than item.slug. The array form was unaffected.

Added ​

  • Added mutation testing with Stryker: stryker.conf.json and a test:mutation npm script. See the new Testing guide page for what mutation testing is and how to read the results.
  • Expanded the test suite with the gaps mutation testing surfaced: effect-stability tests (reactive re-evaluation of selectedRecord/itemList/pagination computeds and the ref-counted loading flag), effect-scope auto-teardown, direct unit tests for stableNormalize and generateFallbackValue, identifier-resolution/batch-edit/guard branches of useStructureDataManagement, and notification auto-hide timeouts.

2.1.0 ​

Added ​

  • Added useStructureSearchApi, a composable built on top of useStructureRestApi that owns everything filter/search-related: fetchSearch, watchSearch (and watchCurrentSearch), checkSearch, searchGet, searchCleanup, resetSearches, isPageCached, isPaginateCached, and a search-scoped pageItemList.
  • Added watchTarget to useStructureRestApi: watches an id source (Ref/ComputedRef/getter) and re-runs fetchTarget whenever it changes, with onSuccess/onError/onSettled callbacks.
  • Exported stableNormalize from src/utils/stableNormalize.ts as part of the public API (previously an internal helper of the REST composable).

Changed ​

  • Search/filter concerns moved out of useStructureRestApi entirely: fetchSearch, searchGet, searchGetTotal, searchSetTotal, searchCleanup, searchCached, searchTotals, and stableNormalize are no longer part of useStructureRestApi — use useStructureSearchApi instead.
  • fetchPaginate on useStructureRestApi is now a generic, filter-agnostic paginated fetch keyed strictly by (lastUpdateKey, pageSize, page). It no longer accepts filters, no longer supports the [items, total] tuple response shape, and no longer manages a total — a search/pagination layer built on top (like useStructureSearchApi) derives its own key from filters and reads any server-reported total out of its own response.
  • resetAll/destroy on useStructureRestApi no longer reset search state (there is none left to reset). Consumers using useStructureSearchApi get merged resetAll/destroy that also clear its searchCached.

Removed ​

  • Removed fetchSearch, SearchApiResult, ISearchCache, searchGet, searchGetTotal, searchSetTotal, and searchCleanup from useStructureRestApi (superseded by useStructureSearchApi).

2.0.1 ​

Added ​

  • Added setInitialData to useStructureFormValidation, to replace the reset baseline that resetForm/isDirty use.
  • Added activateAutoHydrate to useStructureFormValidation: watches a reactive source (e.g. a fetched record) and, once it resolves to a defined value, adopts it as the new reset baseline and applies it to the form.
  • Exposed showFormErrors from useStructureFormValidation.

2.0.0 ​

Added ​

  • Added a richer public API around useStructureRestApi, including:
    • fetchPaginate
    • pre-flight cache/freshness helpers: checkTarget, checkAll, checkByParent, checkAny, checkSearch, checkPaginate, checkMultiple
    • search total helpers: searchGetTotal, searchSetTotal
    • lifecycle helpers: destroy, resetSearches, resetAll
    • exposed queryClient
    • exposed maxRecords
  • Added generateFallbackValue() export.
  • Added lastInsertedIdentifier, lastInsertedIdentifiers, and lastInsertedRecord to data-management/rest composables.
  • Added complete documentation site pages for composables and stores.
  • Added CI, docs deployment, commitlint, and husky-based repository automation.

Changed ​

  • useStructureRestApi was substantially reworked.
  • TanStack Query is now the core cache/freshness engine behind the REST composable, replacing the older internal TTL bookkeeping approach.
  • Caching behavior is now more robust and explicit:
    • per-query freshness is handled through a query client
    • search/page caches are better namespaced
    • per-item cache seeding is shared across list/search/detail/mutation flows
  • fetchSearch now supports both:
    • plain item arrays
    • tuple responses in the form [items, total]
  • searchGet now takes pageSize into account, making search caches page-size-aware.
  • fetchPaginate is now the dedicated pagination-oriented API instead of relying on custom fetchAll patterns.
  • Root exports now use export *, making more types/helpers part of the public surface.
  • Notifications and identifier generation now use a safer fallback id generator rather than assuming crypto.randomUUID() is always available.
  • Loading management is now ref-counted, making concurrent requests safer.
  • Tooling/scripts were modernized:
    • package version bumped to 2.0
    • prettier changed to check mode by default
    • new complete:check script
    • husky setup moved to prepare
  • Documentation and README were rewritten to reflect the actual library direction and usage.

Improved ​

  • Optimistic update/delete/create flows are more reliable, including rollback behavior.
  • Cache invalidation and stale handling are more coherent across list/detail/search flows.
  • Search key generation is now stable even for nested objects.
  • Parent/child relation handling and record lifecycle behavior are more explicit.
  • Test coverage was heavily expanded and reorganized around:
    • unit behavior
    • TTL/freshness behavior
    • pagination
    • search
    • lifecycle
    • modifier semantics
    • end-to-end intent scenarios

Notes ​

  • This release is not just an internal refactor: the public API evolved slightly, especially around pagination, search totals, cache inspection, and lifecycle control.
  • The biggest architectural shift is that TanStack became the backbone of fetch freshness, deduplication, and cache coordination, avoiding more custom wheel-reinventing in the data layer.

Documentation for @guebbit/vue-toolkit v5.2.0. Released under the AGPL-3.0 license.