Appearance
Extension Guardrails — ranh giới khi viết extension cho ODP
Đọc tài liệu này trước khi code một extension (đặc biệt nếu bạn là AI agent). Mục tiêu: làm đúng việc, đúng chỗ; không làm lại thứ ODP đã có; không chạm vào nội bộ core để sau này update core không làm vỡ extension.
Bản đồ kiến trúc + bề mặt extension theo từng phần: Backend API Sitemap. Cách scaffold/build/dev: Extension Development.
0. Checklist trước khi viết bất kỳ dòng code nào
- ODP đã có tính năng này chưa? → xem §3. Nếu có, dùng lại, không tự viết.
- Cần Hook hay Endpoint? → §2. Hook = phản ứng theo sự kiện/biến đổi dữ liệu; Endpoint = thêm HTTP route mới.
- Chỉ import từ
@odp/api/types. Không import deep path (@odp/api/dist/...,src/...). → §4. - Mọi CRUD (kể cả collection riêng của extension) → ưu tiên cao nhất
context.services.ItemsService(collection, { knex, accountability, schema })vớiaccountabilitycủa request.ItemsServicechạy được trên collection custom, và chỉ qua nó bạn mới có permission + audit/activity + revision + parsing query (filter/sort/aggregate/relation). Không query thẳng bảngodp_*. context.database(Knex thô) chỉ dùng khi thật sự cần —ItemsServicekhông biểu đạt được (query quá phức tạp, luồng lớn cần transaction bọc thủ công, bulk/aggregation đặc thù). Hiểu rõ: raw DB bỏ qua permission, audit log và parsing query.- Khai báo quyền ở
package.json#odp-extension.permissions; kiểm tra trong endpoint bằngcontext.validateAppAccess(...). - Không monkey-patch, không mở server/port riêng, không bypass permission. → §5.
Nếu một yêu cầu không thể thực hiện trong ranh giới này → dừng và hỏi, đừng "chế" giải pháp luồn lách.
1. Extension là gì — và bề mặt được phép dùng
Một extension là package có package.json với khối odp-extension. 3 loại:
| Loại | Dùng khi |
|---|---|
hook | Phản ứng theo sự kiện vòng đời (before/after CRUD, submit version, server start...) |
endpoint | Thêm HTTP route mới |
bundle | Gộp cả hook + endpoint trong 1 extension (khuyến nghị) |
Toàn bộ những gì extension được phép chạm gói trong ApiExtensionContext (truyền vào hook/endpoint):
ts
import { defineHook, defineEndpoint } from '@odp/api/types';
interface ApiExtensionContext {
services; // các service class: ItemsService, UsersService, ... (dùng cho mọi nghiệp vụ)
emitter; // onFilter/onAction/onInit (hook) + emitFilter/emitAction/emitInit (phát event)
database; // Knex thô — CHỈ khi ItemsService không đủ (xem §2); bỏ qua permission/audit/parsing
getSchema(); // SchemaOverview hiện tại
env; logger; // cấu hình + log
validateAppAccess; // kiểm tra app-permission trong endpoint
registry; // chia sẻ service giữa các extension (provide/consume) — xem §6
}Hook nhận (context); endpoint nhận (router /* Fastify scoped */, context). Đó là toàn bộ bề mặt được hỗ trợ — ngoài phạm vi này là nội bộ.
2. Làm gì → ở đâu
| Bạn cần... | Dùng | Không làm |
|---|---|---|
| CRUD trên một collection (kể cả collection riêng của extension) | new context.services.ItemsService(coll, { knex, accountability, schema }) — ưu tiên cao nhất | Query/Knex thô (mất permission/audit/parsing) |
| Phản ứng khi item tạo/sửa/xoá | Hook context.action('items.create' | 'items.update' | 'items.delete', fn) hoặc scoped '<collection>.items.create' | Polling DB, trigger DB thủ công |
| Sửa/validate payload trước khi ghi | Hook context.filter('items.create', fn) (trả payload đã sửa, hoặc throw để chặn) | Sửa dữ liệu sau khi đã ghi |
| Thêm HTTP route | endpoint: router.get('/path', handler) (prefix tự gắn theo id) | Mở Fastify/HTTP server riêng |
| Gửi thông báo (email/in-app/mattermost) | context.emitter.emitAction('notify.trigger', { template_id, recipients, variables }, ctx) | Tự gọi SMTP/HTTP gửi mail |
| Tạo in-app notification | context.services.NotificationsService | Ghi thẳng odp_notifications |
| Kiểm tra quyền truy cập module | await context.validateAppAccess(accountability, module, action, scope, knex) | Tự kiểm tra role/permission |
| Lấy schema collection/field | await context.getSchema() | Đọc thẳng odp_collections/_fields |
| Chạy định kỳ (cron) | Hook context.schedule('*/5 * * * *', fn) | setInterval toàn cục |
| Chạy khi server start/stop | Hook context.init('server.start', fn) | |
| Lưu dữ liệu riêng của extension | Tạo collection (CollectionsService) rồi CRUD qua ItemsService(collection, {...}) | Nhồi vào odp_*; mặc định nhảy sang context.database |
Query quá phức tạp / transaction lớn / bulk mà ItemsService không kham | context.database (Knex) — chỉ khi thật cần, tự lo permission | Dùng raw DB cho CRUD thông thường |
| Quản lý collection/field động | context.services.CollectionsService / FieldsService / RelationsService | DDL Knex thủ công trên schema hệ thống |
| Versioning / duyệt nội dung | context.services.VersionsService + workflow (event versions.submit) | Tự xây versioning |
Khi hook tự ghi lại đúng collection mình đang lắng nghe → truyền
{ emitEvents: false }để tránh vòng lặp vô hạn.
3. ODP đã có sẵn — DÙNG, không viết lại
Trước khi định "tự xử lý", kiểm tra danh sách này. Hầu hết nhu cầu đã có service tương ứng (đầy đủ trong Sitemap):
- CRUD + query (filter
_eq/_in/_and/_or, sort, aggregate, search, phân trang) →ItemsService/ query engine. Đừng tự parse query hay viết SQL. - Permission field-level & row-level → tự áp dụng khi gọi service kèm
accountability. Đừng tự lọc quyền. - Auth / session / SSO / sub-token / impersonation →
AuthService,SubTokenService,ImpersonationService. Đừng tự làm JWT/login. - Versioning, revisions, activity log, comments →
VersionsService,RevisionsService,ActivityService,CommentsService. - Files + transform ảnh + storage (local/s3) + upload resumable →
FilesService,AssetsService. Đừng tự xử lý upload/resize. - Thông báo đa kênh + template + queue → event
notify.trigger/NotificationsService. Đừng tự gửi mail. - Workflow duyệt nhiều bước → định nghĩa workflow + trigger qua
versions.submit/items.*. Đừng tự xây state machine duyệt. - Settings, presets, translations/i18n, shares →
SettingsService,PresetsService,TranslationsService,SharesService. - GraphQL, WebSocket realtime →
GraphQLService,WebSocketService. - Lỗi chuẩn hoá →
throwcác error từ hệ thống (đừng tự định nghĩa format lỗi HTTP).
4. Cam kết ổn định vs Nội bộ (để update core không vỡ extension)
Core chỉ cam kết giữ ổn định bề mặt công khai. Phụ thuộc vào nội bộ = bản update core sau này sẽ làm vỡ extension của bạn.
✅ Được phép phụ thuộc (ổn định)
- Import từ
@odp/api/types:defineHook,defineEndpoint, và các type (ApiExtensionContext,HookConfig,EndpointConfig...). ApiExtensionContextvà các method công khai củacontext.services.*.- Sự kiện có tài liệu:
items.create/update/delete/read(+<collection>.items.*),items.sort,versions.submit,collections.*,fields.*,sub-tokens.create/revoke,notify.trigger,extensions.register,server.start/stop. - Manifest
odp-extension(id, type, path/entries, permissions). - CLI:
odp,odp-extension.
❌ KHÔNG được phụ thuộc (nội bộ — có thể đổi bất cứ lúc nào)
- Import deep path:
@odp/api/dist/...,service/api/src/..., hay bất cứ gì ngoài@odp/api/types. - Internals: AST query engine,
security/*, middleware, DB dialects,permissions/*(hàm validate-*), cấu trúc bảngodp_*. - Method/field không có tài liệu của service.
- Hành vi phụ (side-effect) không được cam kết.
Quy tắc vàng: nếu nó không nằm trong
@odp/api/typeshoặcApiExtensionContexthoặc danh sách sự kiện trên → coi như nội bộ, đừng đụng.
5. DO / DON'T
✅ DO
- Dùng
context.servicescho mọi nghiệp vụ; truyềnaccountabilitycủa request để giữ đúng quyền. - Dữ liệu riêng của extension → tạo collection rồi CRUD qua
ItemsService(ưu tiên cao nhất — có permission/audit/parsing). Chỉ rơi xuốngcontext.database(raw) khiItemsServicethật sự không kham (query quá phức tạp, transaction lớn, bulk) — và hiểu là bỏ qua permission/audit/parsing. - Thêm route qua
endpoint; khai báo quyền ở manifest; kiểm bằngvalidateAppAccess. - Hook tự ghi lại →
{ emitEvents: false }. - Lỗi:
throwerror chuẩn của hệ thống với thông điệp rõ ràng.
❌ DON'T (gồm các "chiêu trò" cấm)
- Làm lại tính năng đã có (CRUD, auth, permission, versioning, notify, files...) — xem §3.
- Import nội bộ/deep path. Chỉ
@odp/api/types. - Query/sửa thẳng bảng
odp_*— luôn qua service. - Mặc định nhảy sang
context.databasecho CRUD — đó là lựa chọn cuối, không phải mặc định; CRUD (kể cả collection riêng) đi quaItemsServicetrước. - Bypass permission: tránh
accountability: nullđể "cho nhanh"; chỉ dùng null cho thao tác system có chủ đích và hiểu rõ hậu quả. - Monkey-patch / override service, Fastify instance, prototype, global; ghi đè logic core.
- Hardcode SQL trùng logic core, hardcode URL/secret/credential.
- Mở HTTP server/port riêng, hay đăng ký route ngoài cơ chế
endpoint. - Phụ thuộc thứ tự side-effect hoặc chi tiết triển khai không cam kết.
- "Vá" tạm bằng cách đọc/sửa state nội bộ của core để đạt mục tiêu trước mắt.
6. Chia sẻ service giữa các extension (Service Registry)
Khi extension A muốn cấp một service cho extension B / endpoint / hook khác dùng (vd metaService dựng lúc boot với database/getSchema), không nhét vào context.services (đó là barrel core, không phải chỗ của extension) và không import trực tiếp module của A vào B (coupling cứng — A vắng mặt là B vỡ). Dùng context.registry.
ts
import { createServiceRef } from '@odp/api/types';
// Contract — CHỈ interface + ref, không implementation. A và B cùng import.
// Package này tự do: private nội bộ org, hay public khi open source — tuỳ bạn.
export interface MetaService { resolve(id: string): Promise<Meta> }
export const metaServiceRef = createServiceRef<MetaService>('myorg.meta');Provider (A) — đăng ký ở init 'extensions.register':
ts
export default defineHook((hook, ctx) => {
hook.init('extensions.register', () => {
ctx.registry.provide(metaServiceRef, new MetaServiceImpl(ctx.database, ctx.getSchema));
});
});Consumer (B) — đọc lazy (trong request handler / sau khi load xong); A có thể vắng:
ts
export default defineEndpoint((router, ctx) => {
router.get('/x', async () => {
const meta = ctx.registry.tryConsume(metaServiceRef); // typed | undefined
return meta ? meta.resolve('1') : fallback(); // A chưa cài → vẫn chạy
});
});Luật bắt buộc
- Phụ thuộc CONTRACT, không phụ thuộc PROVIDER. B import
metaServiceRef(contract), không bao giờ import A. A là một implementation, có thể vắng —tryConsumetrảundefined, đó là trạng thái hợp lệ, B phải degrade. provideởinit('extensions.register')(phase đăng ký, await tuần tự trước khi seal).consume/tryConsumelazy — trong request handler, scheduled job, action handler, hoặc init sauextensions.register. Không consume ở top-level body của hook (chạy trước seal → bị chặn).- Không cần contract chung được (vd ext 3rd-party, contract private không chia sẻ): dùng string id + interface tự khai +
has()— mất type-safety đổi lấy zero-coupling:tsinterface MetaLike { resolve(id: string): Promise<{ title: string }> } if (ctx.registry.has('myorg.meta')) { const meta = ctx.registry.tryConsume<MetaLike>('myorg.meta')!; }
Self-diagnosing (dev)
Consume trước khi load xong (sai kỷ luật) → tryConsume trả undefined + log error kèm stack (1 lần/id); consume (hard) → throw với message phân biệt "consumed before registry sealed" vs "no provider". Lỗi này deterministic theo tập extension → dev gặp ngay lần boot đầu, fix là hết.
| Cần | Dùng | Không làm |
|---|---|---|
| A cấp service cho B | ctx.registry.provide(ref, impl) ở init('extensions.register') | Nhét vào context.services; import module của A vào B |
| B dùng service của A (A optional) | ctx.registry.tryConsume(ref) lazy, degrade nếu undefined | consume top-level; giả định A luôn có |
| B bắt buộc cần A | ctx.registry.consume(ref) (throw nếu thiếu) | nuốt lỗi rồi chạy tiếp với state sai |
| Không share được contract | string id + has() + interface tự khai | ép import contract private của bên khác |
7. Khi bí
Nếu nhu cầu không làm được trong ranh giới trên (vd cần một sự kiện core chưa phát, cần một khả năng core chưa expose): dừng lại, ghi rõ cái còn thiếu, và đề xuất bổ sung vào core (thêm sự kiện/method công khai) — thay vì luồn lách nội bộ. Mở rộng bề mặt công khai của core là việc của core, không phải của extension.