npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@onderwijsin/directus-extension-utils

v0.6.2

Published

Reusable utilities for Onderwijs in Directus extensions

Readme

@onderwijsin/directus-extension-utils

Small, reusable utilities for Directus extensions. The package is runtime-portable across Directus setups, but is intended to run inside Directus—not as a framework-agnostic utility library.

The /app entry point exports curated iconifyCollections and iconifyCollectionChoices for Directus interface options. Vue app extensions can import the shared picker from @onderwijsin/directus-extension-utils/app/iconify-picker and the image renderer from @onderwijsin/directus-extension-utils/app/iconify-image. These source components live in components/ and are compiled by the consuming extension's Vue build. Their TypeScript helpers come from the compiled /app/iconify subpath. They use Directus Studio's registered UI components.

The public surface includes:

  • runtime guards (including Directus primary-key narrowing), attempt/retry helpers, object helpers, MIME classification, and UUIDs;
  • server-only async Express adapters, locks, debounced auto-task handlers, task storage, logging, extension setup helpers, composable configuration fragments, and the Studio Docs article seeding contract; and
  • reusable Directus extension types.

Install

pnpm add @onderwijsin/directus-extension-utils

The server utilities use their own Zod runtime dependency. Consumers do not need to install or align Zod when defining extension options through the package's schema builders. A consumer-owned Zod schema can also be passed directly to validateExtensionOptions; this is a fully supported API. When extension options combine shared configuration provided by extension-utils, use defineExtensionOptionsSchema with declarative include fragments and build extension fields with its options callback. Shared dependencies are deduplicated by fragment identity, include order does not change behavior, and duplicate top-level keys between distinct shared fragments fail clearly. When options repeats an included key, the shared schema parses it first and the extension schema adds validation or narrowing to that result; both constraints must succeed, and the canonical shared value is preserved. Defaults or transforms on overlapping options keys do not replace the shared output. The specialized builders remain convenience APIs for one shared fragment. An extension may use another Zod version for unrelated validation, but must not combine schemas from that runtime with the raw shared schemas exported by this package. Use the z supplied to each builder callback for the complete options definition, including nested schemas and helper output.

ensureDirectusDocumentation is the server-only contract for extensions that contribute articles to the fixed studio_docs collection. It validates stable article input, honors the docs seed gate, and reconciles changed content using either the main item or the reserved incoming content version. Call it from a startup documentation callback; the coordinator runs documentation independently of the ordinary extension schema and data gates.

Import

Use the root entry point for common helpers:

import { isRecord, uuid } from '@onderwijsin/directus-extension-utils'

Use /server for server-only utilities:

import {
  asyncHandler,
  createAutoTaskHandler,
  initializeCache,
  withCache,
  createRedisTaskHandlerStorage,
  createRedisLockProvider,
  ensureDirectusDocumentation,
} from '@onderwijsin/directus-extension-utils/server'

Use /hook when defining an API hook with asynchronous action handlers. This subpath contains the corrected Directus hook types and keeps @directus/extensions-sdk isolated from consumers that only import server utilities:

import { defineHook } from '@onderwijsin/directus-extension-utils/hook'

Import only the corrected hook types from /types when a utility needs the compile-time contract without the hook runtime:

import type { RegisterFunctions } from '@onderwijsin/directus-extension-utils/types'

Use the server policy utilities when an extension must resolve Directus policy assignments, including nested roles and ip_access filtering. The first accountability controls whose policy assignments are resolved; by default it also controls CRUD filtering while reading directus_access and directus_policies:

import {
  fetchPolicies,
  hasPolicies,
  initializePolicyCache,
} from '@onderwijsin/directus-extension-utils/server'

const policyCache = initializePolicyCache(env)
const policies = await fetchPolicies(accountability, services, schema, policyCache)
const allowed = await hasPolicies(accountability, policyId, services, schema, policyCache)

Pass a final null read accountability only for a trusted server-side consumer that must resolve assignments without CRUD filtering. This bypass reads policy metadata with system accountability and must not be used before returning policies to a client unless exposing all matching policy metadata is intentional:

const policies = await fetchPolicies(accountability, services, schema, policyCache, null)
const allowed = await hasPolicies(accountability, policyId, services, schema, policyCache, null)

The policies endpoint uses the default user-scoped mode. Its callers therefore need read access to the relevant directus_access and directus_policies records, directly or through a role.

Initialize a policy cache once during extension startup with initializePolicyCache. It returns null when caching is disabled or Redis configuration is invalid. Pass that cache to policy helpers; they never initialize Redis clients on the request path. The Redis-only policy cache uses the shared directus:policies namespace so separate extension entrypoints can invalidate it.

Create a local or Redis-backed cache from validated Directus environment values:

import { initializeCache, withCache } from '@onderwijsin/directus-extension-utils/server'

const cache = initializeCache(context.env, {
  ttl: 60_000,
  namespace: 'directus:extensions:my-extension:summary',
})
const summaryCacheKey = (collection: string): string => `summary:${collection}`

const summary = await withCache({ cache, key: summaryCacheKey('orders') }, () =>
  loadSummary('orders'),
)

withCache accepts a Cache | null, an explicit cache key, and an asynchronous cache-miss handler:

withCache<TResult>(
  options: { cache: Cache | null; key: string },
  handler: () => Promise<TResult>,
): Promise<TResult>

The handler runs only on a cache miss, and its resolved value is stored under the explicit key. A null cache bypasses cache reads and writes while still invoking the handler. Keep key construction next to the cached operation and use stable extension-specific prefixes such as fields:<collection> or summary:<collection>; key construction has no implicit extension prefix.

For mutation-driven invalidation, use registerCollectionCacheInvalidation with the same key function used by withCache:

registerCollectionCacheInvalidation(
  'articles',
  { cache, key: (collection) => `fields:${collection}` },
  hook,
  context,
)

Invalidation deletes the exact key associated with the mutated collection. It is non-blocking and logs deletion failures; use Redis when invalidation must be visible across Directus processes. Pass a string for regular item events, an array for explicit event targets, or an object to select create/update/delete events and system-collection handling.

The related public types are CacheEnv, CacheOptions (ttl, a finite positive number), WithCacheOptions (cache plus key), CollectionInput, and CollectionCacheInvalidationOptions.

Use defineCacheConfigSchema when adding extension-specific fields to the shared cache settings:

import { defineCacheConfigSchema } from '@onderwijsin/directus-extension-utils/server'

export const envSchema = defineCacheConfigSchema((z) => ({
  CATALOG_CACHE_NAMESPACE: z.string().default('catalog'),
}))

Publishable extensions must provide an explicit extension-specific CacheOptions.namespace for extension-owned caches. Use separate subsystem namespaces when targeted invalidation is useful. CacheOptions.namespace isolates Redis keys and makes clear() namespace-scoped. Local caches have a private store per initialized instance; a namespace does not make separate local instances share data. cacheConfigSchema validates Directus cache and Redis settings. REDIS takes precedence over REDIS_HOST, REDIS_PORT, REDIS_USERNAME, and REDIS_PASSWORD; component configuration requires REDIS_ENABLED=true and all four values. Disabled caching returns null; an unset CACHE_STORE uses memory, even though initializeCache maps memory to the library's local backend. TTL values must be finite and positive. Policy caching is the exception: it deliberately ignores memory and requires valid Redis configuration.

The server entrypoint also exports defineEmailConfigSchema, defineRequiredEmailConfigSchema, and isEmailConfigured. The optional builder supplies Directus defaults without requiring a transport; the required builder validates the minimum shared prerequisites for the selected transport. For SMTP, that means only EMAIL_SMTP_HOST; the SMTP port, username, password, and other transport options are left to Directus and the consumer to configure and validate. The corresponding raw schemas remain available for backward compatibility.

Wrap asynchronous endpoint handlers and middleware with asyncHandler so rejected promises reach Directus's Express 4 error handling:

The callback receives an Express request typed with Directus's accountability property, which is Accountability | null.

router.post(
  '/route',
  asyncHandler(async (request, response) => {
    const result = await doSomething(request)
    response.json(result)
  }),
)

Middleware can call next() explicitly after asynchronous work:

router.use(
  asyncHandler(async (_request, _response, next) => {
    await checkAccess()
    next()
  }),
)

Use /sentry when an extension explicitly needs the Sentry helpers. This separate entry point keeps Sentry out of consumers that only import /server utilities:

import { captureException } from '@onderwijsin/directus-extension-utils/sentry'

The shared Directus extension build configuration is available as an unbundled package subpath:

import config, {
  createExtensionConfig,
} from '@onderwijsin/directus-extension-utils/extension.config.js'

export default createExtensionConfig({ externals: ['oxfmt'] })
// Or: export default config

Use /constants for shared deployment-environment values:

import { DEPLOYMENT_ENV, deploymentEnvs } from '@onderwijsin/directus-extension-utils/constants'

const environment: DEPLOYMENT_ENV = 'development'
console.log(deploymentEnvs, environment)

deploymentEnvs is the readonly tuple ['development', 'staging', 'production'], and DEPLOYMENT_ENV is its corresponding TypeScript union.

Use the accountability factories when a service needs admin permissions. The system variant identifies the operation as owned by Directus system code:

import {
  createAdminAccountability,
  createSystemAdminAccountability,
} from '@onderwijsin/directus-extension-utils'

const admin = createAdminAccountability()
const systemAdmin = createSystemAdminAccountability()

Create a logger from a Pino-compatible runtime logger, or use the console-backed fallback:

import { createLogger } from '@onderwijsin/directus-extension-utils/server'

const logger = createLogger(context.logger)
logger.info({ msg: 'Extension started', extension: 'orders' })

When a logger is provided, it is returned unchanged. Without one, the fallback exposes the same info, warn, error, debug, and trace methods and forwards messages plus optional fields to the corresponding console methods.

The /app and /shared entry points expose the common browser-safe surface. Do not import locks, tasks, task storage, logging, or setup helpers from those paths.

Zod-safe extension options

defineExtensionOptionsSchema has two intentional forms. The simple builder callback remains available for a complete consumer-owned schema:

import { defineExtensionOptionsSchema } from '@onderwijsin/directus-extension-utils/server'

export const envSchema = defineExtensionOptionsSchema((z) =>
  z.object({
    MY_EXTENSION_ENABLED: z.boolean().default(true),
  }),
)

Use the object form when the extension describes its top-level options as a shape. include accepts one or more package-owned fragments, while options defines extension-owned fields and may add stricter requirements to included fields with the Zod runtime owned by extension-utils:

// env.schema.ts
import {
  cacheConfig,
  defineExtensionOptionsSchema,
  directusStartupConfig,
} from '@onderwijsin/directus-extension-utils/server'

export const envSchema = defineExtensionOptionsSchema({
  include: [directusStartupConfig, cacheConfig],
  options: (z) => ({
    MY_EXTENSION_ENABLED: z.boolean().default(true),
    CACHE_ENABLED: z.literal(true),
  }),
})

The object form requires include, options, or both. include is optional when options is present:

export const envSchema = defineExtensionOptionsSchema({
  options: (z) => ({
    MY_EXTENSION_ENABLED: z.boolean().default(true),
  }),
})

The callback form remains available when the complete consumer-owned definition is more than a top-level object shape.

Composition is order-independent. A fragment's transitive dependencies are collected once by object identity, so the example composes Redis once even though startup and cache both depend on it. Each fragment owns an explicit shallow top-level shape and any cross-field refinement it needs; the package does not inspect Zod internals. If two different fragments declare the same top-level key, schema materialization throws a duplicate-key error. When options repeats a fragment key, the shared stage completes first, including field defaults, transforms, and fragment cross-field refinements. The overlapping options schema then validates that parsed value, but its defaulted or transformed output is discarded: the canonical shared value remains in the result. This is additional validation or narrowing, not override or last-wins behavior. Extension-owned keys that do not occur in a fragment keep their normal defaults, transforms, output values, and output types.

The seven public shared configuration fragments are:

| Fragment | Adds | Dependencies | | ----------------------- | ------------------------------------------------------------------------- | ------------------------ | | aiConfig | Shared DIRECTUS_EXTENSIONS_AI_* provider configuration. | None. | | redisConfig | Directus Redis configuration. | None. | | synchronizationConfig | Synchronization store configuration. | redisConfig. | | cacheConfig | Cache configuration and its Redis cross-field validation. | redisConfig. | | emailConfig | Optional email transport configuration. | None. | | requiredEmailConfig | Selected-transport email prerequisites. | emailConfig. | | directusStartupConfig | Directus startup, locking, and rate-limiter configuration and validation. | synchronizationConfig. |

Use createExtensionOptionsConfigFragment from /server when an extension needs a local composable fragment with its own cross-field refinement. Keep the fragment's fields and refinement focused on one configuration concern.

The specialized builders remain convenience APIs implemented through the same fragment composer:

| Builder | Equivalent included fragment | | ----------------------------------- | ---------------------------- | | defineAiConfigSchema | aiConfig | | defineRedisConfigSchema | redisConfig | | defineSynchronizationConfigSchema | synchronizationConfig | | defineCacheConfigSchema | cacheConfig | | defineEmailConfigSchema | emailConfig | | defineRequiredEmailConfigSchema | requiredEmailConfig | | defineDirectusStartupSchema | directusStartupConfig |

The returned ExtensionOptionsDefinition<Output> is opaque. Most consumers rely on inference; the type-only ExtensionOptionsConfigFragment, ExtensionOptionsSchemaBuilder, and ExtensionOptionsShapeBuilder exports are available when a helper needs to name a fragment, ordinary-schema callback, or shared-shape callback.

Shared AI configuration

aiConfig validates the optional DIRECTUS_EXTENSIONS_AI_PROVIDER, DIRECTUS_EXTENSIONS_AI_MODEL, DIRECTUS_EXTENSIONS_AI_API_KEY, and DIRECTUS_EXTENSIONS_AI_BASE_URL environment values. resolveAiConfig applies values within matching provider layers in this order: extension option overrides, extension-specific environment, shared environment, then a provider-matched Directus credential. Directus supplies credentials—not a general default provider or model—so a provider and model must still be selected by a higher-precedence layer. Validate the result with resolvedAiConfigSchema, which requires a non-blank provider, model, and API key and accepts an optional base URL.

Use readDirectusAiSettings with an internal SettingsService constructed without accountability. That makes Directus decrypt the special: [encrypt] provider keys. Call it only after authorizing the extension request, keep the returned values server-side, and never include them in a response. The helper reads OpenAI, Anthropic, Google, and OpenAI-compatible credentials plus the compatible provider base URL. Other providers require an extension-specific or shared API key.

Use /server setup helpers at an API extension boundary:

import {
  extensionSetup,
  validateExtensionOptions,
} from '@onderwijsin/directus-extension-utils/server'
import { envSchema } from './env.schema'

const setup = extensionSetup('my-extension', env, logger)
setup.start()
if (!setup.isEnabled()) return
const options = validateExtensionOptions(env, envSchema, logger)
// Register routes or other API behavior using options.
setup.end()

Schema definitions created by these builders are intentionally opaque: do not call .parse(), .safeParse(), .extend(), or .safeExtend() on them. Define every field inside the callback and use validateExtensionOptions to obtain the inferred, validated output. The specialized builders defineAiConfigSchema, defineRedisConfigSchema, defineSynchronizationConfigSchema, defineCacheConfigSchema, defineEmailConfigSchema, defineRequiredEmailConfigSchema, and defineDirectusStartupSchema add their matching shared fragment before validation.

Build nested schemas with the builder callback's runtime. Make a reusable nested-schema helper a factory that receives that callback value, rather than closing over a separately imported z:

import { defineExtensionOptionsSchema } from '@onderwijsin/directus-extension-utils/server'

export const envSchema = defineExtensionOptionsSchema((z) => {
  const catalogFields = (builderZ: typeof z) => ({
    CATALOG_CONNECTION: builderZ.object({
      URL: builderZ.url(),
      TIMEOUT: builderZ.number().int().positive().default(5_000),
    }),
  })

  return z.object(catalogFields(z))
})

The supplied callback value is the builder's mixed-runtime safeguard. The package does not traverse Zod's internal schema graph or reject schemas captured from another runtime, so using a separately imported z inside the callback can reintroduce version-sensitive behavior. validateExtensionOptions also fully supports a consumer-owned raw Zod schema. Passing a standalone consumer-owned schema directly does not mix Zod runtimes. The mixed-runtime risk arises when a consumer-owned schema is composed with a raw shared schema from extension-utils that uses a different Zod runtime. When using extension-utils-provided shared configuration, prefer fragment composition or the corresponding one-fragment builder because both supply the package-owned runtime. The package continues to export its raw shared schemas for compatibility; combining them across Zod runtimes remains version-sensitive.

For extensions that modify Directus schema, compose the entrypoint environment schema with the shared server-side schema-change settings:

import {
  defineExtensionOptionsSchema,
  directusStartupConfig,
} from '@onderwijsin/directus-extension-utils/server'

const envSchema = defineExtensionOptionsSchema({
  include: [directusStartupConfig],
  options: (z) => ({
    MY_EXTENSION_SCHEMA_CHANGES_ENABLED: z.boolean().default(true),
  }),
})

defineDirectusStartupSchema remains the concise equivalent when startup is the only shared configuration fragment.

The fragment validates DIRECTUS_EXTENSIONS_SCHEMA_CHANGES_ENABLED, which defaults to true, and supports DIRECTUS_EXTENSIONS_LOCK_PROVIDER (memory, redis, or fs). When unset, the lock provider falls back to SYNCHRONIZATION_STORE. redis uses DIRECTUS_EXTENSIONS_LOCK_REDIS_URL when set, otherwise the resolved Directus Redis configuration; fs requires DIRECTUS_EXTENSIONS_LOCK_FS_DIRECTORY. It also exposes the shared DIRECTUS_EXTENSIONS_RATE_LIMITER_STORE setting. When unset, it falls back to SYNCHRONIZATION_STORE.

Use ensureDirectusSchema from the same /server subpath to apply portable collection, field, and relation definitions. Use withCollectionIdentity(name, schema) when a bundled portable schema supports a configurable collection name. Every collection definition must include a non-blank schema.name and its primary-key field in the collection's nested fields array; do not repeat that primary-key field in the top-level fields array. This prevents Directus from creating an implicit integer primary key before the extension's intended field is applied. Pass the Directus hook context's database, getSchema, and services, and provide a logger plus an extension identifier. Existing compatible resources are preserved; incompatible structural resources are logged loudly and left unchanged rather than being silently modified. The validated environment options select the provider automatically. Set options.lockProvider to override that selection programmatically. Redis providers created from environment options are disposed after the ensure operation; explicitly supplied providers remain owned by the consumer.

Use validateSchemaDefinition(...) for bundled schema JSON before passing it to ensureDirectusSchema; no type cast is required.

Use validatePolicyDefinition(...) for bundled policy JSON with nested permissions, then pass the validated definition to ensureDirectusPolicy. The policy ensure operation processes the nested permissions into directus_permissions rows linked to the policy; permission IDs are generated by Directus as integers and must not be included in bundled definitions. Idempotency is based on the natural key policy + collection + action; matching existing rows are preserved and are not updated.

extensionSetup logs lifecycle messages and supports an environment-based enabled flag. validateExtensionOptions materializes package-owned schema definitions, parses a complete extension environment with Zod, logs validation details, and throws when the configuration is invalid.

Schema configuration and operation options:

| Option | Scope | Default | Purpose | | -------------------------------------------------------------- | ----------- | ---------------- | ------------------------------------------------------------------------------------ | | DIRECTUS_EXTENSIONS_SCHEMA_CHANGES_ENABLED | global | true | Master switch for schema setup. | | DIRECTUS_EXTENSIONS_DATA_SEED_ENABLED | global | true | Enables policy and future data seeds. | | SYNCHRONIZATION_STORE | Directus | memory | Global fallback for synchronization-related extension stores. | | DIRECTUS_EXTENSIONS_LOCK_PROVIDER | global | unset | Selects memory, redis, or fs; otherwise falls back to SYNCHRONIZATION_STORE. | | DIRECTUS_EXTENSIONS_LOCK_REDIS_URL | global | — | Optional override; otherwise uses resolved Redis settings. | | DIRECTUS_EXTENSIONS_LOCK_FS_DIRECTORY | global | — | Required for the filesystem provider. | | DIRECTUS_EXTENSIONS_RATE_LIMITER_STORE | global | unset | Selects the limiter store; otherwise falls back to SYNCHRONIZATION_STORE. | | REDIS_ENABLED | Directus | false | Enables component-based Redis configuration. | | REDIS | Directus | — | Complete Redis URL; takes precedence over components. | | REDIS_HOST, REDIS_PORT, REDIS_USERNAME, REDIS_PASSWORD | Directus | — | Required together when constructing a Redis URL. | | lockProviderConfig | operation | — | Uses validated environment config to create a provider. | | lockProvider | operation | — | Supplies a consumer-owned provider directly. | | autoRenew | coordinator | true | Renews the startup lease while callbacks run. | | abortOnError | coordinator | true | Rethrows provider, callback, and lost-lease failures after cleanup. | | lockLeaseMs | operation | provider default | Overrides one lock acquisition lease. |

ensureDirectusSchema always coordinates the operation with a lock and returns { changed, skipped }. It creates missing resources, skips compatible resources, and logs incompatible collections, fields, or relations without changing them. Structural compatibility is deliberately narrow: collection identity, field identity/type, and relation endpoints are authoritative; interfaces, displays, labels, icons, visibility, notes, and similar UI metadata are left under the site's control. It logs an info-level pre-operation plan and post-operation summary; per-resource and lock lifecycle details use debug-level logging. Bundled extension definitions are trusted data and do not need a second runtime Zod schema.

Use getDirectusStartupStatus to check the shared startup lock from another code path without acquiring, renewing, releasing, or repairing it:

import { getDirectusStartupStatus } from '@onderwijsin/directus-extension-utils/server'

const status = await getDirectusStartupStatus({
  id: 'orders',
  options: { lockProviderConfig: options },
})

if (status.isLocked) {
  // Schema and data startup work is still in progress.
}

The status query must use the same provider configuration and extension identifier as the startup coordinator. It is read-only and disposes only providers created from configuration. Use Redis or a shared filesystem provider for separate processes.

Use ensureDirectusPolicy for policy data seeds. It creates a policy with its configured UUID and name, preserves compatible policies, and idempotently creates its nested permission rows. It logs UUID/name conflicts without modifying existing policies. Role assignments and user assignments are separate future seeds.

Register startup work through createDirectusStartupCoordinator. It coordinates each phase with a lock, registers schema callbacks on app.before, and registers data and documentation callbacks on the awaited middlewares.before lifecycle event. Documentation callbacks are independent of the ordinary global schema/data gates.

Register startup.schema(), startup.data(), and startup.documentation() callbacks synchronously during extension setup, in any order. The coordinator registers lifecycle listeners synchronously only for phases that are both used and enabled. Additional callbacks reuse the existing listeners and run in callback registration order within their group. Unused or disabled phases register no listeners and perform no startup coordination. Existing lifecycle events, locking, and error handling remain unchanged, regardless of consumer call order. Independent init listeners do not guarantee sequential execution between groups.

During synchronous registration, schema() ignores callbacks when disabled or disabledGlobally is true; data() also ignores callbacks when dataDisabledGlobally is true. Disabled callbacks are not retained. The existing disabled-phase message is logged once per phase during registration. documentation() bypasses all three switches and still registers and executes.

Treat coordinator option values as immutable for the lifetime of a coordinator. Configuration changes take effect the next time startup work is registered with a new coordinator, typically after a service restart or in a separate CLI invocation. Execution-time checks defensively guard registered callbacks; they cannot re-enable callbacks discarded during registration within that same coordinator.

const startup = createDirectusStartupCoordinator(hook, logger, {
  id: 'orders',
  name: 'Orders',
  disabled: false,
  disabledGlobally: !options.DIRECTUS_EXTENSIONS_SCHEMA_CHANGES_ENABLED,
  dataDisabledGlobally: !options.DIRECTUS_EXTENSIONS_DATA_SEED_ENABLED,
  lockProviderConfig: options,
})

startup.schema(async ({ lockProvider }) => {
  await ensureDirectusSchema({
    id: 'orders',
    database,
    getSchema,
    logger,
    services,
    definition: ordersDefinition,
    options: { lockProvider },
  })
})

Enabled schema callbacks register on Directus's awaited app.before lifecycle event, while enabled data callbacks register on Directus's awaited middlewares.before lifecycle event. Provider and callback failures are logged and then rethrown by default after the coordinator releases its lease and disposes any provider it created. Set abortOnError: false only for deliberate best-effort startup.

const startup = createDirectusStartupCoordinator(hook, logger, {
  id: 'studio-docs',
  name: 'Studio Docs',
  disabled: false,
  disabledGlobally: false,
  dataDisabledGlobally: false,
})

Contribute a stable article from a startup documentation callback. The helper is server-only and targets the fixed studio_docs collection owned by @onderwijsin/directus-studio-docs-bundle:

import { ensureDirectusDocumentation } from '@onderwijsin/directus-extension-utils/server'

startup.documentation(async ({ lockProvider }) => {
  await ensureDirectusDocumentation(
    {
      id: '7b8b3a1e-38f3-4ab7-9b37-5e4c5d7f1234',
      navigation_label: 'Getting started',
      body: '# Getting started\n\nWrite the article in Markdown.',
    },
    context,
    { lockProvider, extensionName: 'Orders' },
  )
})

ensureDirectusDocumentation honors DIRECTUS_DOCS_ENABLED and DIRECTUS_DOCS_SEED_ENABLED. Documentation callbacks are not blocked by DIRECTUS_EXTENSIONS_SCHEMA_CHANGES_ENABLED or DIRECTUS_EXTENSIONS_DATA_SEED_ENABLED; those gates continue to control ordinary extension work. Pass extensionSeedEnabled: false to opt out one contributor. Stable UUIDs in the id field identify the article; icon and archived have defaults. The default versioning strategy updates the reserved incoming version, while override replaces the main item. Incoming content is never promoted automatically.

The coordinator renews its startup lease by default while callbacks run. Set autoRenew: false only when every callback is guaranteed to finish within the configured lease. Nested schema and data ensures receive a borrowed provider and cannot release the coordinator-owned lease. If renewal is lost, the coordinator stops before running the next callback and logs the failure. If release returns false, ownership was lost and the coordinator reports that failure instead of claiming a successful release.

All lock providers use the same tryAcquire/isLocked/lease contract and defaultLeaseMs option. Choose the memory provider for one process, the filesystem provider for processes sharing a directory, or the Redis provider for shared coordination across replicas. When creating memory providers directly, use the same providerId for callers that must coordinate and different IDs for isolated lock namespaces.

Auto-task handlers clear a marker only after the task succeeds. Task failures and lost leases are reported through onError and leave the marker pending for a later trigger; failed tasks are not automatically retried. Tasks should honor the supplied AbortSignal and be safe to run again.

Documentation

Start with the extension-utils cookbook article for usage examples and the utility glossary for coordination terminology. Maintainers can use the API reference for the complete export and option surface.

Provider-bound configuration

Provider-sensitive inheritance is restricted to matching providers. A layer without a provider belongs to the provider inherited from lower layers; lower-layer models, API keys, and base URLs never acquire an overridden provider from above. When changing provider, supply a matching model and credentials in that layer, another matching layer, or provider-matched Directus settings. Unbound lower-layer credentials are not inherited.

An empty or whitespace-only DIRECTUS_EXTENSIONS_AI_API_KEY environment value is treated as unset, allowing startup without an AI key and provider-matched credential fallback. Generation still requires a complete resolved configuration.