Appearance
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-sdkThe 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 bundleThen install dependencies and start dev mode:
bash
cd extensions/my-extension
pnpm install
pnpm run devThe 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>| Flag | Required | Default | Description |
|---|---|---|---|
-n, --name | Yes | — | Extension folder name (also used as package name and ID) |
-t, --type | No | bundle | Extension type: hook, endpoint, or bundle |
The command creates under EXTENSIONS_PATH (default ./extensions):
package.jsonwithodp-extensionmetadata and build scriptstsconfig.jsonconfigured 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 bundlebuild
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 --watchThe build process:
- Reads
package.jsonforodp-extensionmetadata - For single extensions (hook/endpoint): builds
src/index.ts→dist/index.js - For bundles: auto-generates a virtual entry from
entriesinpackage.json, builds →dist/index.js, cleans up the generated file - 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>| Flag | Required | Description |
|---|---|---|
-n, --name | Yes | Entry name |
-t, --type | Yes | Entry type: hook or endpoint |
Example:
bash
cd extensions/analytics
odp-extension add -n webhook-handler -t endpoint
odp-extension add -n data-enricher -t hookThis 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.tsneeded. The build tool auto-generates a virtual entry point from theentriesarray inpackage.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 endpointEach 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-sdkas a devDependency. The SDK ships withtsupandtypescript— you don't install these separately.
| Field | Type | Description |
|---|---|---|
odp-extension.id | string | Unique extension identifier (matches folder name) |
odp-extension.type | "hook" | "endpoint" | "bundle" | Extension type |
odp-extension.entries | array | Bundle only — list of sub-entries with type, name, and source |
odp-extension.entries[].source | string | Path to the entry's source file (e.g. src/hooks/index.ts). If omitted, the build tool guesses by convention. |
odp-extension.path | string | Optional 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/defineEndpointidentity functions - The
odp-extensionCLI for building (wraps tsup programmatically) tsupandtypescriptas 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-sdkto 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:
| Pattern | Matches |
|---|---|
'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
| Event | Type | Meta Fields |
|---|---|---|
items.create | action/filter | collection, key, payload |
items.update | action/filter | collection, keys, payload |
items.delete | action/filter | collection, keys |
items.read | action/filter | collection, query |
extensions.register | init | — (register shared services here; see Sharing Services Between Extensions) |
server.start | init | — |
server.stop | init | — |
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:
| Property | Type | Description |
|---|---|---|
services | ExtensionServices | All ODP service constructors (see below) |
database | Knex | Database connection (Knex query builder) |
logger | Logger | Pino logger scoped to the extension |
env | Record<string, unknown> | Environment variables |
getSchema() | () => Promise<SchemaOverview> | Returns the current database schema (cached, queries real DB) |
emitter | ExtensionEmitter | Event emitter for subscribing to filter/action events |
validateAppAccess() | Function | Check app-level permissions for the current user |
registry | ServiceRegistry | Share services across extensions — provide/consume/tryConsume/has (see Sharing Services Between Extensions) |
cache | ExtensionCache | Per-extension key/value cache — get/set/remove/has/clear/keys (see Caching) |
EventContext (passed to filter/action handlers)
| Property | Type | Description |
|---|---|---|
database | Knex | Database connection |
schema | SchemaOverview | Schema snapshot at event time |
accountability | Accountability | null | Current 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,
});| Service | Collection/Purpose |
|---|---|
ItemsService | Generic CRUD for any collection |
UsersService | odp_users — user management |
RolesService | odp_roles — role management |
FilesService | odp_files — file upload/management |
AssetsService | File asset transformation/delivery |
CollectionsService | Schema — create/update/delete collections |
FieldsService | Schema — create/update/delete fields |
RelationsService | Schema — manage relations (M2O, O2M, M2M, M2A) |
PermissionsService | odp_permissions — CRUD permissions |
PoliciesService | odp_policies — access policies |
ActivityService | odp_activity — activity log |
RevisionsService | odp_revisions — revision tracking |
VersionsService | odp_versions — content versioning |
CommentsService | odp_comments — item comments |
NotificationsService | odp_notifications — user notifications |
PresetsService | odp_presets — saved filter/layout presets |
SettingsService | odp_settings — global settings |
TranslationsService | odp_translations — custom translations |
SharesService | odp_shares — public shares |
MailService | Send emails via configured transport |
AuthService | Authentication (login, refresh, logout) |
SchemaService | Schema snapshot/diff/apply |
ImportExportService | Data import/export |
PayloadService | Payload transformation (hashing, JSON, etc.) |
MetaService | Collection metadata (count, etc.) |
UtilsService | Utility operations (hash, UUID, etc.) |
GraphQLService | GraphQL query execution |
WebSocketService | WebSocket connection management |
AppPermissionsService | App-level module permissions |
ExtensionSettingsService | Extension configuration storage |
SubTokenService | Sub-token management |
UserProvidersService | SSO provider linking |
ImpersonationService | User impersonation |
ProviderSettingsService | Auth/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.register — not 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) });
});
});| Method | Behaviour |
|---|---|
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 });
});
});| Store | When to use | Flushed by |
|---|---|---|
shared (default, context.cache) | Volatile, cheap-to-recompute values | Permission/role/policy changes; every mutation if CACHE_AUTO_PURGE=true |
isolated (createCache({ store: 'isolated' })) | Values you don't want wiped by unrelated platform activity | Only 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 });
});
});| Method | Signature | Behaviour |
|---|---|---|
get | get<T>(key, defaultValue?): Promise<T | undefined> | Returns the value, or defaultValue (default undefined) if missing/expired. |
set | set(key, value, options?): Promise<void> | Stores any JSON-serializable value. options.ttl sets expiry. |
remove | remove(key): Promise<void> | Deletes a single key. |
has | has(key): Promise<boolean> | true if the key exists and hasn't expired. |
clear | clear(): Promise<void> | Deletes every key owned by this extension. |
keys | keys(): 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 missingNotes & caveats
- No null-checks needed. When caching is disabled the methods are safe no-ops:
getreturns the default,set/remove/cleardo 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) orExtensionSettingsService. - The default (
context.cache) shares the system cache. Those entries are flushed when permissions/roles/policies change, and on every mutation whenCACHE_AUTO_PURGE=true. Treat them as disposable and keep TTLs short. Switch tocreateCache({ 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 explicitttlwhen you need consistent expiry acrossCACHE_STOREbackends. - 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 increateCache({ 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-setraces under concurrency. For "run this once across instances" usecontext.schedule(backed bySynchronizedClock), 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. nullis cacheable,undefinedis not.get()treats a storedundefinedas a miss and returns your default; a storednullround-trips asnull. If you need to cache "there is genuinely no value", storenull(or a sentinel), neverundefined.- TTL defaults differ by backend. No
ttl→ memory applies its default (5 min); Redis persists until evicted. Always pass an explicitttlfor predictable behavior acrossCACHE_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.cacheavailability vsCACHE_ENABLED.CACHE_ENABLEDgates the HTTP response cache; the system and extension stores initialize independently, socontext.cache/createCachegenerally 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 install2. 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 --watchodp 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-featureOutput 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 installThis 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.tsneeded —odp-extension buildauto-generates the entry point fromentriesinpackage.json.
Environment Variables
| Variable | Default | Description |
|---|---|---|
EXTENSIONS_PATH | ./extensions | Root directory for extension folders |
EXTENSIONS_MUST_LOAD | "" | Comma-separated extension IDs that must load (server fails on error) |
Extension Loading Order
- Server starts, runs database migrations
extensionManager.initialize(app, knex)stores Fastify and DB referencesextensionManager.loadAll()scansEXTENSIONS_PATH- 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
- Resolves entry point:
- Server emits
server.startaction event
Extensions are loaded once here. Dev hot reload = full restart via
odp dev(see Develop), not an in-process watcher.