Skip to content

Extension Development Guide

This guide covers how to create, develop, and deploy custom extensions for the ODP API using @odp/extensions-sdk.

⚠️ Đọc trước: Extension Guardrails — ranh giới được/không được chạm, ODP đã có sẵn gì (đừng làm lại), và cách tránh phụ thuộc nội bộ khiến update core làm vỡ extension.


Prerequisites

Install the SDK in your project:

bash
# In a pnpm workspace — install at root, extensions reference via workspace:*
pnpm add -D @odp/extensions-sdk

# Standalone extension project — install directly
pnpm add -D @odp/extensions-sdk

# Before the SDK is published to a registry — install from local path
pnpm add -D @odp/extensions-sdk@file:/path/to/odp/cli/extensions-sdk

The SDK provides:

  • CLI (odp-extension) — scaffold, build, and manage extensions
  • Type helpers (defineHook, defineEndpoint) — identity functions for TypeScript inference
  • Build toolchain — tsup + typescript bundled as dependencies (extensions don't install these directly)

Quick Start

Scaffold a new extension:

bash
# Using the SDK CLI directly
odp-extension create -n my-extension -t bundle

# Or via the ODP server CLI
odp extension:create -n my-extension -t bundle

Then install dependencies and start dev mode:

bash
cd extensions/my-extension
pnpm install
pnpm run dev

The extension is now watching for changes and rebuilding automatically.


SDK CLI Commands

create

Scaffold a new extension project.

bash
odp-extension create -n <name> -t <type>
FlagRequiredDefaultDescription
-n, --nameYesExtension folder name (also used as package name and ID)
-t, --typeNobundleExtension type: hook, endpoint, or bundle

The command creates under EXTENSIONS_PATH (default ./extensions):

  • package.json with odp-extension metadata and build scripts
  • tsconfig.json configured for ES2022 + bundler resolution
  • Source file templates based on the chosen type

Example:

bash
# Create a hook-only extension
odp-extension create -n audit-logger -t hook

# Create an endpoint-only extension
odp-extension create -n health-check -t endpoint

# Create a bundle with both hooks and endpoints
odp-extension create -n analytics -t bundle

build

Build one or all extensions using tsup (bundled in the SDK).

bash
# Build current directory (run from inside an extension)
odp-extension build

# Build all extensions in EXTENSIONS_PATH
odp-extension build

# Build a specific extension
odp-extension build my-extension

# Watch mode
odp-extension build --watch

The build process:

  1. Reads package.json for odp-extension metadata
  2. For single extensions (hook/endpoint): builds src/index.tsdist/index.js
  3. For bundles: auto-generates a virtual entry from entries in package.json, builds → dist/index.js, cleans up the generated file
  4. Output: ESM format, self-contained (SDK helpers bundled inline)

add

Add a new entry to an existing bundle extension. Run from inside the extension directory.

bash
odp-extension add -n <name> -t <type>
FlagRequiredDescription
-n, --nameYesEntry name
-t, --typeYesEntry type: hook or endpoint

Example:

bash
cd extensions/analytics
odp-extension add -n webhook-handler -t endpoint
odp-extension add -n data-enricher -t hook

This updates package.json entries and creates the source file at src/{type}s/{name}/index.ts.

Server CLI Commands

The ODP server also provides extension commands that delegate to the SDK:

bash
# Scaffold (same as odp-extension create)
odp extension:create -n <name> -t <type>

# Build all extensions (scans EXTENSIONS_PATH, installs deps if needed)
odp extension:build [-n <name>]

Extension Types

Hook

Listens to lifecycle events (item CRUD, server init, scheduled jobs). Does not expose HTTP routes.

my-hook/
├── package.json
├── tsconfig.json
└── src/
    └── index.ts      ← defineHook(...)

Endpoint

Registers custom HTTP routes on the Fastify instance. Does not listen to lifecycle events.

my-endpoint/
├── package.json
├── tsconfig.json
└── src/
    └── index.ts      ← defineEndpoint(...)

Routes are mounted at /{extension-id}/. For example, an extension with ID health-check defining router.get('/ping', ...) is accessible at /health-check/ping.

Bundle

Combines hooks and endpoints in a single package. This is the default type and the recommended approach for most extensions.

my-bundle/
├── package.json
├── tsconfig.json
└── src/
    ├── hooks/
    │   └── index.ts          ← defineHook(...)
    └── endpoints/
        └── index.ts          ← defineEndpoint(...)

No src/index.ts needed. The build tool auto-generates a virtual entry point from the entries array in package.json. You only write the individual hook/endpoint source files.

Bundles can have multiple entries of the same type. Use odp-extension add to add more entries:

bash
odp-extension add -n analytics-hooks -t hook
odp-extension add -n webhook-handler -t endpoint

Each entry gets its own directory: src/hooks/{name}/index.ts or src/endpoints/{name}/index.ts.

Bundle endpoints are mounted at /{entry-name}/, where entry-name comes from the entries array in package.json. This allows a single bundle to expose multiple independent endpoint groups, each with its own route prefix.


Package Metadata

Every extension must have an odp-extension block in package.json. This is how the Extension Manager discovers and classifies extensions.

Hook or Endpoint

json
{
  "name": "my-hook",
  "version": "1.0.0",
  "type": "module",
  "odp-extension": {
    "id": "my-hook",
    "type": "hook"
  },
  "scripts": {
    "build": "odp-extension build",
    "dev": "odp-extension build --watch"
  },
  "devDependencies": {
    "@odp/extensions-sdk": "*"
  }
}

Bundle

json
{
  "name": "analytics",
  "version": "1.0.0",
  "type": "module",
  "odp-extension": {
    "id": "analytics",
    "type": "bundle",
    "entries": [
      { "type": "hook", "name": "analytics-hooks", "source": "src/hooks/index.ts" },
      { "type": "endpoint", "name": "analytics-endpoints", "source": "src/endpoints/index.ts" }
    ]
  },
  "scripts": {
    "build": "odp-extension build",
    "dev": "odp-extension build --watch"
  },
  "devDependencies": {
    "@odp/extensions-sdk": "*"
  }
}

Note: Extensions only need @odp/extensions-sdk as a devDependency. The SDK ships with tsup and typescript — you don't install these separately.

FieldTypeDescription
odp-extension.idstringUnique extension identifier (matches folder name)
odp-extension.type"hook" | "endpoint" | "bundle"Extension type
odp-extension.entriesarrayBundle only — list of sub-entries with type, name, and source
odp-extension.entries[].sourcestringPath to the entry's source file (e.g. src/hooks/index.ts). If omitted, the build tool guesses by convention.
odp-extension.pathstringOptional custom entry point (default: dist/index.js)

Type-Safe Helpers

Import from @odp/extensions-sdk for full TypeScript support:

typescript
import { defineHook } from '@odp/extensions-sdk';
import { defineEndpoint } from '@odp/extensions-sdk';

These are identity functions that enable TypeScript inference — no runtime overhead.

Build & Resolution

Extensions use @odp/extensions-sdk as a devDependency. The SDK provides:

  • TypeScript types and the defineHook/defineEndpoint identity functions
  • The odp-extension CLI for building (wraps tsup programmatically)
  • tsup and typescript as bundled dependencies

When building, the SDK's identity functions are bundled inline (2 lines of JS, zero overhead) — no external resolution needed at runtime. The output is a single self-contained dist/index.js (ESM format).

Important: Do NOT add --external @odp/extensions-sdk to your build. The SDK helpers are bundled into the extension output so it is fully self-contained — similar to how Directus extensions work.

Installation in Workspaces

In a pnpm workspace, install the SDK once at the project root. Each extension references it via workspace protocol:

my-project/
├── package.json          ← "devDependencies": { "@odp/extensions-sdk": "..." }
├── pnpm-workspace.yaml   ← packages: ["extensions/*"]
└── extensions/
    ├── app-crm/
    │   └── package.json  ← "devDependencies": { "@odp/extensions-sdk": "workspace:*" }
    └── audit-log/
        └── package.json  ← "devDependencies": { "@odp/extensions-sdk": "workspace:*" }

For standalone extensions (not in a workspace), install the SDK directly in the extension's package.json.


Writing Hooks

Basic Structure

typescript
import { defineHook } from '@odp/extensions-sdk';

export default defineHook((context, meta) => {
  const { filter, action, init, schedule } = context;
  const { services, database, logger, env, getSchema, emitter, validateAppAccess, cache } = meta;

  // Register event listeners here...
});

Event Types

action — Fire-and-Forget

Runs asynchronously after an operation completes. All handlers run in parallel. Errors are caught and logged — they do not affect the original operation.

typescript
context.action('items.create', async (meta, ctx) => {
  // meta.collection, meta.key, meta.payload — event-specific data
  // ctx.database  — Knex instance
  // ctx.schema    — current schema snapshot
  // ctx.accountability — current user or null
  (logger as any).info({ collection: meta.collection }, 'Item created');
});

Use cases: audit logging, sending notifications, syncing to external systems.

filter — Synchronous Pipeline

Runs sequentially before an operation. Each handler receives the output of the previous one. Return the (possibly modified) payload. Throwing an error aborts the operation.

typescript
context.filter('items.create', async (payload, meta, ctx) => {
  if (meta.collection === 'articles') {
    payload.status = payload.status ?? 'draft';
  }
  return payload; // must return payload
});

Use cases: validation, data enrichment, default values, access control.

init — Server Initialization

Runs sequentially during server startup. Use for one-time setup.

typescript
context.init('server.start', async (meta) => {
  (logger as any).info('Extension initialized');
});

schedule — Cron Jobs

Register recurring tasks using standard 5-field cron expressions. Multi-instance safe via SynchronizedClock.

typescript
context.schedule('0 */6 * * *', async () => {
  // Runs every 6 hours
  const schema = await getSchema();
  const items = await database('stale_items')
    .where('updated_at', '<', new Date(Date.now() - 86400000));
  // ... cleanup logic
});

Using Services in Hooks

Services let you interact with ODP through the business logic layer instead of raw SQL:

typescript
import { defineHook } from '@odp/extensions-sdk';

export default defineHook((context, { services, database, getSchema }) => {
  // Auto-create a related record when an article is published
  context.action('items.update', async (meta, ctx) => {
    if (meta.collection !== 'articles') return;

    const schema = await getSchema();
    const itemsService = new services.ItemsService('articles', {
      knex: database,
      accountability: ctx.accountability,
      schema,
    });

    const article = await itemsService.readOne(meta.keys[0]);
    if (article.status !== 'published') return;

    // Create a notification for the author
    const notifyService = new services.NotificationsService({
      knex: database,
      accountability: null, // system-level, bypass permissions
      schema,
    });

    await notifyService.createOne({
      recipient: article.author,
      subject: 'Article Published',
      message: `Your article "${article.title}" is now live.`,
    });
  });

  // Use services in scheduled jobs
  context.schedule('0 3 * * *', async () => {
    const schema = await getSchema();
    const usersService = new services.UsersService({
      knex: database,
      accountability: null,
      schema,
    });

    // Deactivate users who haven't logged in for 90 days
    const staleUsers = await usersService.readByQuery({
      filter: { last_access: { _lt: new Date(Date.now() - 90 * 86400000).toISOString() } },
      fields: ['id'],
    });

    for (const user of staleUsers) {
      await usersService.updateOne(user.id, { status: 'suspended' });
    }
  });
});

Event Matching Rules

Every item lifecycle mutation emits two events: the generic event and a collection-scoped event. A write on the articles collection fires both items.create and articles.items.create. Register on whichever fits your need:

PatternMatches
'items.create'Generic — fires for every collection (meta.collection identifies which)
'articles.items.create'Collection-scoped — fires only for articles, no guard needed
'items.*'Any event starting with items. (items.create, items.update, etc.) for any collection
'*.items.create'Collection-scoped wildcard — articles.items.create, orders.items.create, ...
'*'All events

Prefer collection-scoped events for single-collection logic

Register filter('c_contacts.items.create', ...) instead of filter('items.create', ...) with an if (meta.collection !== 'c_contacts') return guard. It's targeted, self-documenting, and avoids needless invocations. Both styles coexist — generic handlers still fire for every collection. Within one emit pass the generic event runs before the scoped event, so a generic filter sees the payload first.

Common Events

EventTypeMeta Fields
items.createaction/filtercollection, key, payload
items.updateaction/filtercollection, keys, payload
items.deleteaction/filtercollection, keys
items.readaction/filtercollection, query
extensions.registerinit— (register shared services here; see Sharing Services Between Extensions)
server.startinit
server.stopinit

Writing Endpoints

Basic Structure

typescript
import { defineEndpoint } from '@odp/extensions-sdk';

export default defineEndpoint((router, context) => {
  const { services, database, logger, env, getSchema, emitter, validateAppAccess, cache } = context;

  router.get('/ping', async (_request, reply) => {
    return reply.send({ pong: true });
  });

  router.post('/process', async (request, reply) => {
    const body = request.body as Record<string, unknown>;
    // ... business logic
    return reply.send({ data: { status: 'ok' } });
  });
});

The router is a scoped Fastify instance. All standard Fastify methods are available: get, post, put, patch, delete.

Using Services in Endpoints

Use service constructors from context.services for type-safe, permission-aware access:

typescript
router.get('/stats', async (request, reply) => {
  const schema = await getSchema();
  const accountability = (request as any).accountability;

  const itemsService = new services.ItemsService('articles', {
    knex: database,
    accountability,
    schema,
  });

  const items = await itemsService.readByQuery({ aggregate: { count: ['*'] } });
  return reply.send({ data: items });
});

You can also use raw Knex queries when services are overkill:

typescript
router.get('/raw-count', async (_request, reply) => {
  const count = await database('articles')
    .count('* as total')
    .first();

  return reply.send({ data: { total: count?.total ?? 0 } });
});

Permission Checking

Use validateAppAccess to enforce role-based access:

typescript
router.get('/admin-stats', async (request, reply) => {
  const accountability = (request as any).accountability;

  await validateAppAccess(
    accountability,
    'my-extension',    // module name
    'read',            // action
    null,              // collection scope (null = all)
    database,
  );

  // Only reachable if the user has permission
  const stats = await database('analytics_events').count('* as total').first();
  return reply.send({ data: stats });
});

Runtime Context Reference

Both hooks and endpoints receive an ApiExtensionContext object with these properties:

PropertyTypeDescription
servicesExtensionServicesAll ODP service constructors (see below)
databaseKnexDatabase connection (Knex query builder)
loggerLoggerPino logger scoped to the extension
envRecord<string, unknown>Environment variables
getSchema()() => Promise<SchemaOverview>Returns the current database schema (cached, queries real DB)
emitterExtensionEmitterEvent emitter for subscribing to filter/action events
validateAppAccess()FunctionCheck app-level permissions for the current user
registryServiceRegistryShare services across extensions — provide/consume/tryConsume/has (see Sharing Services Between Extensions)
cacheExtensionCachePer-extension key/value cache — get/set/remove/has/clear/keys (see Caching)

EventContext (passed to filter/action handlers)

PropertyTypeDescription
databaseKnexDatabase connection
schemaSchemaOverviewSchema snapshot at event time
accountabilityAccountability | nullCurrent user context (null for system operations)

Available Services

Extensions receive service constructors (not instances). Instantiate them with { knex, accountability, schema }:

typescript
const schema = await getSchema();
const itemsService = new services.ItemsService('my_collection', {
  knex: database,
  accountability: ctx.accountability, // from EventContext
  schema,
});
ServiceCollection/Purpose
ItemsServiceGeneric CRUD for any collection
UsersServiceodp_users — user management
RolesServiceodp_roles — role management
FilesServiceodp_files — file upload/management
AssetsServiceFile asset transformation/delivery
CollectionsServiceSchema — create/update/delete collections
FieldsServiceSchema — create/update/delete fields
RelationsServiceSchema — manage relations (M2O, O2M, M2M, M2A)
PermissionsServiceodp_permissions — CRUD permissions
PoliciesServiceodp_policies — access policies
ActivityServiceodp_activity — activity log
RevisionsServiceodp_revisions — revision tracking
VersionsServiceodp_versions — content versioning
CommentsServiceodp_comments — item comments
NotificationsServiceodp_notifications — user notifications
PresetsServiceodp_presets — saved filter/layout presets
SettingsServiceodp_settings — global settings
TranslationsServiceodp_translations — custom translations
SharesServiceodp_shares — public shares
MailServiceSend emails via configured transport
AuthServiceAuthentication (login, refresh, logout)
SchemaServiceSchema snapshot/diff/apply
ImportExportServiceData import/export
PayloadServicePayload transformation (hashing, JSON, etc.)
MetaServiceCollection metadata (count, etc.)
UtilsServiceUtility operations (hash, UUID, etc.)
GraphQLServiceGraphQL query execution
WebSocketServiceWebSocket connection management
AppPermissionsServiceApp-level module permissions
ExtensionSettingsServiceExtension configuration storage
SubTokenServiceSub-token management
UserProvidersServiceSSO provider linking
ImpersonationServiceUser impersonation
ProviderSettingsServiceAuth/storage provider configuration

Permission enforcement: Services enforce permissions based on the accountability object you pass. Use null for system-level (bypass all checks) or pass the request's accountability for user-level access.

Suppressing events — emitEvents

All mutation methods (createOne, createMany, updateOne, updateMany, deleteOne, deleteMany) accept an optional MutationOptions argument. Set emitEvents: false to perform a silent mutation that does not fire any items.* hooks — including the batch event emitted by createMany/updateMany:

typescript
await itemsService.updateOne(key, { last_processed: new Date().toISOString() }, {
  emitEvents: false, // no items.update / <collection>.items.update fired
});

The flag defaults to true. The primary use case is avoiding infinite loops: when an action('articles.items.update', ...) hook writes back to the same collection, pass emitEvents: false on that write so the hook does not re-trigger itself.

typescript
context.action('articles.items.update', async (meta, ctx) => {
  const schema = await getSchema();
  const service = new services.ItemsService('articles', { knex: database, accountability: null, schema });

  await service.updateOne(meta.keys[0], { synced_at: new Date().toISOString() }, { emitEvents: false });
});

Sharing Services Between Extensions

An extension can expose a live service (built at boot with database/getSchema/env) for other extensions, endpoints, or hooks to consume — across the hook/endpoint boundary. Use context.registry. Do not import another extension's module directly (hard coupling that breaks when the provider is absent) and do not push into context.services (that is the core barrel).

The rule of thumb: depend on a contract, never on the provider. The provider may or may not be installed — that is a valid state the consumer degrades around.

Contract (shared, implementation-free)

Put just the interface + a typed ref in a tiny package both sides import. This package carries no runtime — it is types only — so installing it is never "noise". It can be private (internal org) or public (when you open-source) — your choice.

typescript
import { createServiceRef } from '@odp/api/types';

export interface MetaService {
  resolve(id: string): Promise<{ title: string }>;
}
export const metaServiceRef = createServiceRef<MetaService>('myorg.meta');

Provider — register in extensions.register

Providers register during the dedicated extensions.register init event. This event is awaited to completion before the registry is sealed, so every provider is in place before any consumer runs.

typescript
import { defineHook } from '@odp/extensions-sdk';
import { metaServiceRef } from '@myorg/meta-contract';

export default defineHook((context, ctx) => {
  context.init('extensions.register', () => {
    ctx.registry.provide(metaServiceRef, new MetaServiceImpl(ctx.database, ctx.getSchema));
  });
});

Consumer — consume lazily

Consume inside a request handler, scheduled job, action handler, or any init that runs after extensions.registernot at the top level of a hook (which runs before the registry is sealed).

typescript
import { defineEndpoint } from '@odp/extensions-sdk';
import { metaServiceRef } from '@myorg/meta-contract';

export default defineEndpoint((router, ctx) => {
  router.get('/title/:id', async (request, reply) => {
    const meta = ctx.registry.tryConsume(metaServiceRef);   // typed | undefined
    if (!meta) return reply.send({ data: null });           // provider not installed → degrade
    const { id } = request.params as { id: string };
    return reply.send({ data: await meta.resolve(id) });
  });
});
MethodBehaviour
provide(ref, impl)Register an implementation under a typed ref. Call in extensions.register.
consume(ref)Hard dependency — returns the typed impl, throws if absent (message distinguishes "consumed before sealed" from "no provider").
tryConsume(ref | id)Soft dependency — returns the impl or undefined. Accepts a typed ref or a plain string id.
has(id)true if a provider is registered for that id.

No shared contract? Use a string id

When you can't share a contract (e.g. a third-party provider whose contract is private), drop to a string id plus a locally-declared interface and a capability check. You trade type-safety for zero coupling:

typescript
interface MetaLike { resolve(id: string): Promise<{ title: string }> }

if (ctx.registry.has('myorg.meta')) {
  const meta = ctx.registry.tryConsume<MetaLike>('myorg.meta')!;
  await meta.resolve('1');
}

Provide at init, consume lazily

A consume that runs before loading finishes (e.g. at a hook's top level) returns undefined (or throws, for consume) and logs an error with a stack trace — once per id. This is deterministic per installed-extension set, so you hit it on the first dev boot and fix it immediately; it does not resurface at runtime for the same set.


Caching (context.cache)

Both hooks and endpoints get a cache on the context — a small key/value store for anything an extension wants to keep between requests: results of expensive computations, upstream API responses, rate-limit counters, cached lookups. It is backed by the platform cache (memory or Redis, per CACHE_STORE), so it works across requests and, on Redis, across instances.

Keys are namespaced per extension automatically. Two extensions can both set('config', …) without colliding, and clear() only wipes the calling extension's keys.

Choosing a store — shared vs isolated

context.cache uses the shared system store by default. If you need entries that survive permission changes and cache auto-purge, build an isolated cache with context.createCache({ store: 'isolated' }) — a dedicated extension-only store that system/response invalidation never touches. Both variants keep the same per-extension key namespace.

typescript
export default defineEndpoint((router, { cache, createCache }) => {
  // Shared store (default) — fine for volatile, cheap-to-recompute values
  const shared = cache;

  // Isolated store — not flushed on permission changes / CACHE_AUTO_PURGE
  const durable = createCache({ store: 'isolated' });

  router.get('/config', async (_request, reply) => {
    const cfg = await durable.get('config');
    return reply.send({ data: cfg });
  });
});
StoreWhen to useFlushed by
shared (default, context.cache)Volatile, cheap-to-recompute valuesPermission/role/policy changes; every mutation if CACHE_AUTO_PURGE=true
isolated (createCache({ store: 'isolated' }))Values you don't want wiped by unrelated platform activityOnly your own remove/clear, TTL expiry, or eviction

API

typescript
export default defineEndpoint((router, { cache }) => {
  router.get('/report', async (_request, reply) => {
    // Serve from cache if present
    const cached = await cache.get<Report>('daily-report');
    if (cached) return reply.send({ data: cached, cached: true });

    // Otherwise compute, cache for 10 minutes, and return
    const report = await buildExpensiveReport();
    await cache.set('daily-report', report, { ttl: '10m' });
    return reply.send({ data: report, cached: false });
  });
});
MethodSignatureBehaviour
getget<T>(key, defaultValue?): Promise<T | undefined>Returns the value, or defaultValue (default undefined) if missing/expired.
setset(key, value, options?): Promise<void>Stores any JSON-serializable value. options.ttl sets expiry.
removeremove(key): Promise<void>Deletes a single key.
hashas(key): Promise<boolean>true if the key exists and hasn't expired.
clearclear(): Promise<void>Deletes every key owned by this extension.
keyskeys(): Promise<string[]>Lists this extension's keys (namespace prefix stripped).

options.ttl accepts a number (milliseconds) or a duration string — '30s', '5m', '1h', '7d'. Omit it to use the store's default TTL.

typescript
await cache.set('token', value, { ttl: 30_000 });   // 30 seconds (ms)
await cache.set('token', value, { ttl: '30s' });     // same, as a string
await cache.get('token', null);                      // null instead of undefined when missing

Notes & caveats

  • No null-checks needed. When caching is disabled the methods are safe no-ops: get returns the default, set/remove/clear do nothing. Your code path stays the same.
  • This is a cache, not storage. Entries can be evicted (LRU/memory pressure) or expire at any time — always be able to recompute the value. For durable data use a collection (ItemsService) or ExtensionSettingsService.
  • The default (context.cache) shares the system cache. Those entries are flushed when permissions/roles/policies change, and on every mutation when CACHE_AUTO_PURGE=true. Treat them as disposable and keep TTLs short. Switch to createCache({ store: 'isolated' }) when you need entries that outlive that invalidation.
  • TTL default differs by store. With no ttl, the memory store applies its default TTL while Redis persists until evicted. Pass an explicit ttl when you need consistent expiry across CACHE_STORE backends.
  • Serialization. On Redis, values are JSON.stringify-ed — store plain data, not class instances or functions.
typescript
// Hook: cache an upstream lookup used by many events, refresh hourly
export default defineHook((context, { cache, logger }) => {
  context.filter('items.create', async (payload, meta) => {
    if (meta.collection !== 'orders') return payload;

    let rates = await cache.get<Rates>('fx-rates');
    if (!rates) {
      rates = await fetchFxRates();               // expensive upstream call
      await cache.set('fx-rates', rates, { ttl: '1h' });
      (logger as any).info('FX rates refreshed');
    }

    payload.total_usd = convert(payload.total, rates);
    return payload;
  });
});

Key namespacing & sharing within a bundle

Keys are prefixed per extension id (ext:<extension-id>:<your-key>), and for a bundle the hook and endpoint entries share the same extension id — so they share one keyspace. This is deliberate and useful: a value written by the endpoint is readable by the hook, and vice-versa.

typescript
// endpoints/index.ts — OAuth callback stores the access token
await cache.set(`oauth:token:${connId}`, token, { ttl: '55m' });

// hooks/index.ts — the scheduled poll reads the same key (same bundle → same namespace)
const token = await cache.get<string>(`oauth:token:${connId}`);

Because everything in a bundle shares one namespace, give each feature its own key prefix (oauth:token:*, ratelimit:*, lookup:*). Same key across call sites = intentional sharing; different keys = isolation. You do not need the service registry to share cached values inside a bundle — the shared namespace already does it. Reach for context.registry only to share a live service object across the hook/endpoint boundary or with another extension.

`clear()` wipes the whole extension, not one feature

clear() deletes every key under ext:<extension-id>: in that store. In a bundle where several features share the namespace, one feature calling clear() nukes the others' entries too. Prefer remove(key) for targeted deletes; reserve clear() for "reset everything this extension cached".

shared and isolated are separate stores

They are backed by two different store instances (system cache vs a dedicated extension cache). Consequences:

  • The same key in context.cache (shared) and in createCache({ store: 'isolated' }) (isolated) are two different entries — writing one does not affect the other.
  • Switching an existing key's store (e.g. moving from shared to isolated) does not migrate data — the old entry is orphaned until it expires/evicts. Re-populate after switching.
  • Full Redis keys (for debugging with redis-cli): shared → <CACHE_NAMESPACE>:system:ext:<id>:<key>, isolated → <CACHE_NAMESPACE>:ext:ext:<id>:<key>.

Edge cases seen in real use

  • Not atomic — don't build locks or exact counters. There is no setNx/INCR/compare-and-swap; get-then-set races under concurrency. For "run this once across instances" use context.schedule (backed by SynchronizedClock), not a cache flag. Counters (e.g. a retry/backoff tally) are best-effort — concurrent writers can undercount; that's fine for backoff, not for anything requiring exactness.
  • Treat returned values as immutable. The memory store returns the same object reference you put in, so mutating a get() result mutates the cached entry (and any other holder). The Redis store returns a fresh JSON copy, so the same mutation is silently lost. To stay correct across both backends, clone before mutating, or only store/replace whole values.
  • null is cacheable, undefined is not. get() treats a stored undefined as a miss and returns your default; a stored null round-trips as null. If you need to cache "there is genuinely no value", store null (or a sentinel), never undefined.
  • TTL defaults differ by backend. No ttl → memory applies its default (5 min); Redis persists until evicted. Always pass an explicit ttl for predictable behavior across CACHE_STORE.
  • The store is bounded and evicts. On the memory backend the extension/isolated store is an LRU capped at a few thousand entries shared across all extensions; high-cardinality keys evict each other. Never assume a key you set is still there — always be able to recompute.
  • DB is the source of truth, cache holds derivations. Store durable data (OAuth refresh token, sync watermarks, dedup keys) in a collection; cache only the cheap-to-refetch derivations (the short-lived access token, a lookup result). A cache flush/eviction must never lose real state.
  • context.cache availability vs CACHE_ENABLED. CACHE_ENABLED gates the HTTP response cache; the system and extension stores initialize independently, so context.cache/createCache generally work regardless. The no-op-on-disabled contract is a safety net (early boot / misconfiguration) — your code path stays the same either way, but don't rely on the cache being off as a feature toggle.

Development Workflow

1. Create

bash
odp-extension create -n my-feature -t bundle
cd extensions/my-feature
pnpm install

2. Develop (hot reload)

Run two processes side by side:

bash
# 1) rebuild the extension on source change (writes dist/, signals the server)
odp-extension build --watch

# 2) run the API in dev mode — restarts when an extension rebuilds
odp dev            # ≡ odp start --watch

odp dev watches a small reload sentinel that odp-extension build --watch touches after each successful build, then does a clean full restart. So a saved change flows: edit src → tsup rebuild → server restart → change live (works for both hooks and endpoints).

Hot reload needs `--watch`

The server only reloads when started with odp dev (or odp start --watch). Plain odp start (prod) never watches.

Tại sao là restart, không phải reload in-place

Fastify khoá router sau khi listen và không có API gỡ/thay route runtime, nên endpoint không thể hot-swap in-process (khác Directus dùng Express mutable router). Vì vậy reload = restart cả process — đúng cho cả hook lẫn endpoint, và giống hệt cách prod boot.

3. Build for Production

bash
# Build current extension
odp-extension build

# Build all extensions in EXTENSIONS_PATH
odp-extension build

# Build a specific extension by name
odp-extension build my-feature

Output goes to dist/index.js (ESM format).

4. Register

Extensions are auto-discovered by the Extension Manager at boot when they have a valid package.json with odp-extension metadata. Bật/tắt bằng cờ enabled trong odp_extensions:

sql
-- disable an extension, then restart/redeploy to apply
UPDATE odp_extensions SET enabled = false WHERE folder = 'my-feature';

Extensions chỉ được load lúc boot (tôn trọng cờ enabled). Đổi trạng thái → restart server (dev) hoặc redeploy (prod).

5. Hot Reload (dev) & Production

  • Dev: dùng odp dev (+ odp-extension build --watch) như mục Develop — đổi file → restart sạch.
  • Production: extensions load 1 lần lúc boot; không watcher. Cập nhật/bật/tắt = build + redeploy (Docker). Không có reload runtime in-process.

Complete Example: Audit Logger Bundle

bash
odp-extension create -n audit-logger -t bundle
cd extensions/audit-logger
pnpm install

This generates a bundle with entries in package.json:

json
{
  "odp-extension": {
    "id": "audit-logger",
    "type": "bundle",
    "entries": [
      { "type": "hook", "name": "audit-logger-hooks", "source": "src/hooks/index.ts" },
      { "type": "endpoint", "name": "audit-logger-endpoints", "source": "src/endpoints/index.ts" }
    ]
  }
}

src/hooks/index.ts — Log all item mutations:

typescript
import { defineHook } from '@odp/extensions-sdk';

export default defineHook((context, { database, logger }) => {
  const collections = ['articles', 'products', 'orders'];

  context.action('items.create', async (meta, ctx) => {
    if (!collections.includes(meta.collection as string)) return;

    await database('audit_log').insert({
      action: 'create',
      collection: meta.collection,
      item_id: meta.key,
      user_id: ctx.accountability?.user ?? 'system',
      timestamp: new Date(),
    });
  });

  context.action('items.update', async (meta, ctx) => {
    if (!collections.includes(meta.collection as string)) return;

    for (const key of meta.keys as string[]) {
      await database('audit_log').insert({
        action: 'update',
        collection: meta.collection,
        item_id: key,
        user_id: ctx.accountability?.user ?? 'system',
        timestamp: new Date(),
      });
    }
  });

  context.action('items.delete', async (meta, ctx) => {
    if (!collections.includes(meta.collection as string)) return;

    for (const key of meta.keys as string[]) {
      await database('audit_log').insert({
        action: 'delete',
        collection: meta.collection,
        item_id: key,
        user_id: ctx.accountability?.user ?? 'system',
        timestamp: new Date(),
      });
    }
  });
});

src/endpoints/index.ts — Query audit logs:

typescript
import { defineEndpoint } from '@odp/extensions-sdk';

export default defineEndpoint((router, { database, validateAppAccess }) => {
  router.get('/logs', async (request, reply) => {
    const accountability = (request as any).accountability;
    await validateAppAccess(accountability, 'audit-logger', 'read', null, database);

    const query = request.query as Record<string, string>;
    const limit = Math.min(parseInt(query.limit ?? '50', 10), 200);
    const offset = parseInt(query.offset ?? '0', 10);

    const logs = await database('audit_log')
      .orderBy('timestamp', 'desc')
      .limit(limit)
      .offset(offset);

    const [{ total }] = await database('audit_log').count('* as total');

    return reply.send({
      data: logs,
      meta: { total_count: total, filter_count: logs.length },
    });
  });
});

No src/index.ts needed — odp-extension build auto-generates the entry point from entries in package.json.


Environment Variables

VariableDefaultDescription
EXTENSIONS_PATH./extensionsRoot directory for extension folders
EXTENSIONS_MUST_LOAD""Comma-separated extension IDs that must load (server fails on error)

Extension Loading Order

  1. Server starts, runs database migrations
  2. extensionManager.initialize(app, knex) stores Fastify and DB references
  3. extensionManager.loadAll() scans EXTENSIONS_PATH
  4. For each enabled extension:
    • Resolves entry point: odp-extension.path > package.json.main > dist/index.js
    • Dynamically imports the module
    • Detects type from exports (hooks/endpoints/default)
    • Registers hooks with the event emitter and/or endpoints with Fastify
  5. Server emits server.start action event

Extensions are loaded once here. Dev hot reload = full restart via odp dev (see Develop), not an in-process watcher.

ODP Internal API Documentation