Backend

Directus

Explain the fetcher mapping and auth integration in @ginjou/with-directus.

@ginjou/with-directus provides two adapters for the Directus SDK.

It gives you a Ginjou fetcher and a Ginjou auth provider. Most apps pass the same Directus client to both.

This package does not change how higher-level Ginjou hooks are used. It only connects them to Directus.

Installation

Install @directus/sdk together with the adapter.

pnpm add @ginjou/with-directus @directus/sdk
@directus/sdk@ginjou/with-directus
>=20.0.0 <26.0.0latest
^15.0.00.1.0-beta.16
SDK 20.0.0 changed client.login() from positional arguments to a payload object, so the latest release requires 20 or newer. SDK 1619 were never a declared peer range, but they kept the positional login(), so 0.1.0-beta.16 works there aside from the peer-dependency warning.

Fetcher

Use createFetcher() with a Directus client that has REST support.

PropRequiredMeaning
clientYesA Directus client with REST capabilities.

Register it through defineFetchersContext().

import { authentication, createDirectus, rest } from '@directus/sdk'
import { defineFetchersContext } from '@ginjou/vue'
import { createFetcher } from '@ginjou/with-directus'

const directus = createDirectus('https://your-directus.example.com')
    .with(rest({ credentials: 'include' }))
    .with(authentication('session', { autoRefresh: false, credentials: 'include' }))

defineFetchersContext({
    default: createFetcher({ client: directus }),
})

The adapter implements getList, getOne, getMany, createOne, createMany, updateOne, updateMany, deleteOne, deleteMany, and custom.

getMany reads by id with an _in filter and sets limit to the number of ids, ignoring any meta.query.limit, which could only truncate the result. The filter goes into _and, so an id filter you pass through meta.query.filter is kept as well.

When Directus answers with no body

Directus writes first and then reads the row back to build its response. A policy that grants the write but not the read makes that read-back forbidden, and Directus answers success with no body rather than undoing a write that already succeeded. Deletes always answer with no body, since there is nothing left to read.

Rather than hand you an empty result, the adapter rebuilds the record from what it just sent.

MethodReturned when the body is empty
createOneThe params you passed.
createManyThe params array you passed.
updateOne{ ...params, id }.
updateManyOne { ...params, id } per id.
deleteOne{ id }.
deleteManyOne { id } per id.

A real body always wins; this only fills a gap. The one thing that cannot be recovered is a server-generated key on create, so a createOne whose response was withheld comes back without an id unless you supplied one yourself.

Collections and System Collections

Normal resources use the generic SDK item helpers such as readItems() and updateItem().

System resources are different. If the resource starts with directus_ or directus/, the adapter switches to the matching dedicated SDK helper.

Resource nameDirectus SDK helper pattern
postsreadItems, readItem, createItem, createItems, updateItem, updateItems, deleteItem, deleteItems
directus_usersreadUsers, readUser, createUser, createUsers, updateUser, updateUsers, deleteUser, deleteUsers

That lets one resource name work for both normal collections and Directus system collections.

Pagination

List pagination is translated into Directus page and limit.

Ginjou inputDirectus query
pagination.currentpage
pagination.perPagelimit

For totals, the adapter runs a second aggregate() request.

By default it asks Directus for countDistinct: 'id'.

Filters

The adapter converts Ginjou filters into Directus filter objects.

Ginjou operatorDirectus operator
eq_eq
ne_neq
lt_lt
gt_gt
lte_lte
gte_gte
in_in
nin_nin
contains_icontains
containss_contains
ncontains_nicontains
ncontainss_ncontains
null_null
nnull_nnull
between_between
nbetween_nbetween
startswith_istarts_with
startswiths_starts_with
nstartswith_nistarts_with
nstartswiths_nstarts_with
endswith_iends_with
endswiths_ends_with
nendswith_niends_with
nendswiths_nends_with
or_or
and_and

Ginjou operators without an s suffix are case-insensitive and map to the Directus _i* variants. The s suffix means case-sensitive and maps to the plain Directus operators.

An unknown operator throws instead of being dropped.

There are a few extra rules worth knowing.

CaseBehavior
Filter on searchRouted into Directus search.
getList() default filterNone. The adapter adds nothing of its own.
Empty values in meta.query.filterSent as written.
Empty _and / _or group in filters, or a top-level empty _and in meta.query.filterDropped.

The adapter runs no scrubbing pass over your query, so _eq: null, _eq: '' and _in: [] reach Directus unchanged. These are real conditions, and _eq: null in particular is how you ask for rows that were never soft-deleted. Dropping it would silently widen the query to the whole collection. Keys you leave as undefined are omitted by the Directus SDK itself.

A top-level empty group in filters is the one thing dropped, and dropping it changes nothing. Directus turns _or: [] into an empty SQL group that its query builder omits, so the query it runs is identical whether the group is sent or not. An empty top-level _and in meta.query.filter goes the same way, since the resolved filters are merged into that same array. An empty group nested deeper is forwarded rather than hunted down.

_in: [] is the opposite case and is always sent: it compiles to 1 = 0 and matches no rows, so dropping it would silently turn "match nothing" into "match everything".

Anything you want applied to every list query goes through meta.query.filter, and is merged with the filters resolved from filters. A status gate, for example:

const { data } = useList({
    resource: 'posts',
    meta: {
        query: {
            filter: { status: { _neq: 'archived' } },
        },
    },
})

Sorters

Sorters are converted into Directus sort strings.

Ginjou sorterDirectus sort entry
{ field: 'title', order: 'asc' }title
{ field: 'createdAt', order: 'desc' }-createdAt

Multiple sorters are joined with commas.

For example, title asc plus createdAt desc becomes title,-createdAt.

Custom

custom() accepts filters and sorters, converts them with the same rules as above, and sends them as the filter and sort query parameters. Your own query is applied last and wins any key it sets.

custom({
    url: '/items/posts',
    method: 'get',
    filters: [{ field: 'status', operator: 'eq', value: 'published' }],
    sorters: [{ field: 'date', order: 'desc' }],
})
// GET /items/posts?filter={"_and":[{"status":{"_eq":"published"}}]}&sort=-date

Meta

The verified meta entry points are meta.query and meta.aggregate.

Meta fieldUsed byWhat it does
meta.querygetList, getOne, createOne, updateOnePass through Directus query options such as fields, filter, sort, page, and limit.
meta.aggregategetListOverride the aggregate descriptor used for total count.

groupBy exists in the current type, but it is not used by the implementation.

Do not rely on it as a working feature yet.

import { useGetList } from '@ginjou/vue'

useGetList({
    resource: 'posts',
    meta: {
        query: {
            fields: ['id', 'title', 'user_created.first_name'],
            filter: {
                status: {
                    _eq: 'published',
                },
            },
        },
    },
})
import { useGetList } from '@ginjou/vue'

useGetList({
    resource: 'posts',
    meta: {
        aggregate: {
            count: '*',
        },
    },
})

Auth

Use createAuth() with a Directus client that has authentication and REST support.

Most apps reuse the same Directus client they already passed to createFetcher().

Register it through defineAuthContext().

import { authentication, createDirectus, rest } from '@directus/sdk'
import { defineAuthContext } from '@ginjou/vue'
import { createAuth } from '@ginjou/with-directus'

const directus = createDirectus('https://your-directus.example.com')
    .with(rest({ credentials: 'include' }))
    .with(authentication('session', { autoRefresh: false, credentials: 'include' }))

defineAuthContext(createAuth({ client: directus }))

Login

Two login types are supported, and they work in completely different ways.

Login typeInputWhat happens
passwordemail, password, optional optionsclient.login({ email, password }, options)
ssoprovider, optional redirectFull page navigation to /auth/login/{provider}?redirect=…

Password login:

import { useLogin } from '@ginjou/vue'

const { mutateAsync: login } = useLogin()

await login({
    type: 'password',
    params: {
        email: 'user@example.com',
        password: 'password123',
    },
})

SSO login navigates the browser away from your app, so nothing after the call runs:

import { useLogin } from '@ginjou/vue'

const { mutateAsync: login } = useLogin()

await login({
    type: 'sso',
    params: {
        provider: 'google',
        // Defaults to the current page.
        redirect: 'https://app.example.com/callback',
    },
})
The SDK cannot perform this login for you. client.login(payload, { provider }) posts credentials to the same endpoint, which only works for the local and ldap drivers — OAuth2, OpenID and SAML need the browser itself to make the trip so the provider can redirect it back.

Directus must be configured to allow the return trip, and to hand back a session cookie:

AUTH_<PROVIDER>_MODE="session"
AUTH_<PROVIDER>_REDIRECT_ALLOW_LIST="https://app.example.com/callback"

Create the client in session mode so that cookie is sent with later requests:

import { authentication, createDirectus, rest } from '@directus/sdk'

const directus = createDirectus('https://directus.example.com')
    .with(authentication('session', { autoRefresh: false, credentials: 'include' }))
    .with(rest({ credentials: 'include' }))

Once the browser lands back on your app, check() picks the session up on its own — there is no callback code to write. See Check.

If you would rather render the endpoint as a plain link than call login(), getSSOLoginUrl(client, provider, redirect?) returns the same URL without navigating.

import { getSSOLoginUrl } from '@ginjou/with-directus'

const href = getSSOLoginUrl(directus, 'google')

Logout

logout() maps to client.logout().

It clears the Directus session through the SDK.

Identity

getIdentity() maps to client.request(readMe()).

That returns the current Directus user profile.

Check Authentication

check() reads client.getToken(), and falls back to client.refresh() once when there is no token.

Set autoRefresh: false: this adapter synchronizes refresh with logout, whereas the SDK's automatic refresh can otherwise restore credentials after logout.

Set credentials: 'include' on both rest() and authentication(): session mode keeps the session in a cookie, and cross-origin requests only send it when the fetch opts in.

Token stateResult
Token exists{ authenticated: true }
No token, refresh succeeds{ authenticated: true }
No token, refresh fails{ authenticated: false }

The fallback matters because an empty token store is not proof of being logged out. client.getToken() never issues a request of its own, and the SDK stores tokens in memory by default, so the store is empty in two ordinary situations:

  • The browser has just come back from an SSO redirect, carrying only the cookie Directus set.
  • The page was reloaded, and no persistent storage was configured on authentication().

Without the fallback both look like a logged-out user. With it, one POST /auth/refresh recovers the session and no callback route is needed after SSO.

The cost is one failed refresh request per check() for a genuinely anonymous visitor. Concurrent checks share a single in-flight request.

Check Error

checkError() inspects Directus client errors.

It returns { logout: true } only for verified auth-related Directus error codes.

Directus error codeBehavior
TOKEN_EXPIREDReturn { logout: true, error }
INVALID_CREDENTIALSReturn { logout: true, error }
INVALID_IPReturn { logout: true, error }
INVALID_OTPReturn { logout: true, error }

Other errors return an empty object, so non-auth failures do not force logout automatically.

Copyright © 2026