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

directus-typed-kit

v0.2.0

Published

Schema-typed toolkit for building Directus hook & endpoint extensions

Readme

directus-typed-kit

npm license

為 Directus 的 hook / endpoint extension 補上完整型別。註冊一次專案 Schema,hook payload、ItemsService、knex 查詢結果就全程 typed。runtime 100% 沿用原生 defineHook / defineEndpoint,kit 只加型別與組合便利層。

安裝

npm i directus-typed-kit

@directus/extensions-sdk@directus/errors@directus/typesknex 為 peerDependencies,由宿主 extension 提供。

註冊 Schema

把專案 Schema 註冊一次,createHook / createEndpoint 就免逐次帶泛型。放進一個由 tsconfig include 納入的 .d.ts

// src/directus-typed-kit.d.ts
import type { Schema } from './schema' // 你的 collection 型別

declare module 'directus-typed-kit' {
  interface KitSchema { schema: Schema }
  // interface KitTypes { PrimaryKey: string }
}

export {} // 勿刪

Schema 契約

Schema 自行定義(手寫或由 generator 產出後轉換),kit 只要求四件事:

| 契約 | 寫法 | 為什麼 | | --- | --- | --- | | 容器 | collection: Row[],singleton 用裸 Row | collection key 即 items() / knex() 的表名 | | 關聯 | m2o 寫 string \| Row、o2m 寫 string[] \| Row[] | 讀取展開與否由 fields 決定,兩種形狀都要在型別裡;kit 也靠「有沒有 FK 那半」把關聯與 json 欄位分開,o2m 漏寫 string[] 會被當成 json | | nullable | field: T \| null(不用 field?: T) | 讀取結果欄位恆在、值才可能 null;optional 會多帶假的 undefined | | 日期 / conceal | 標 brand(下方範例) | 同一欄位在 items 與 knex 兩視角型別不同,kit 靠 brand 分流 |

import type { Concealed, DateOnly, Timestamp } from 'directus-typed-kit'

interface Article {
  id: string;
  title: string;
  body: string | null;
  published_on: DateOnly | null; // API 回 'YYYY-MM-DD'、knex 回 Date
  created_at: Timestamp; // API 回 ISO 帶 Z、knex 回 Date
  author: string | Author | null; // m2o:未展開是 FK、展開是 row
  tags: string[] | ArticleTag[]; // o2m:FK 在 child 表,故 knex 視角無此欄
  meta: Record<string, unknown> | null; // json:無 FK 那半故非關聯,兩視角都原樣
}
interface Author {
  id: string;
  name: string;
  password: Concealed; // items 讀出是遮蔽字串故型別上移除,要真值改走 knex
}
interface ArticleTag { id: string; article: string | Article; label: string }

export interface Schema {
  articles: Article[];
  authors: Author[];
  site_settings: { id: string; project_name: string }; // singleton:裸 row、非陣列
}

日期 brand 有 Timestamp(UTC)、DateTime(無時區)、DateOnlyTimeOnly 四種,差別只在 knex 視角:前三者為 DateTimeOnlystring(node-pg 把 time 當字串)。

系統表(directus_*)不內建於 Schema:services.UsersService() 等內建 service 開箱可用,要 typed 的 items('directus_users') / knex('directus_files') 則自行把需要的表加進 Schema 或 KnexOverrides

Hook

以時序命名對應原生事件,middleware 可組合:

import { createHook, validate } from 'directus-typed-kit/hook'

export default createHook(({ beforeCreate, afterUpdate, items, logger }) => {
  // payload 自動 typed 為 Partial<Schema['share_links']>;不回傳 → 沿用原 payload
  beforeCreate('share_links', [requireShare, validate(ShareSchema)], async (payload) => {
    payload.password_hash = hashPassword(payload.password_hash as string)
  })

  // ItemsService 完整 typed:結果依 fields 收斂,巢狀點記逐層推導
  afterUpdate('files', async ({ keys }) => {
    const rows = await items('files').readMany(keys, { fields: ['id', 'folder.name'] })
    for (const r of rows) logger.info({ id: r.id, folder: r.folder.name })
  })
})
  • beforeCreate / beforeUpdate / beforeDelete → 原生 filter;afterCreate / afterUpdate / afterDelete → 原生 action
  • 其他事件用 filter(event, …) / action(event, …)schedule / init / embed 直通原生
  • before* 的第三參數 ctxdatabase——本次 mutation 的交易,查得到尚未 commit 的變更(參照完整性檢查須用它)。handler 內的 items() / services / transaction() 也自動落在該交易,不必逐處傳
  • after* 的 ctx 沒有 database:action 事件在交易 commit 後才 emit,已無交易可用。在 after* 裡寫入失敗不會回滾原 mutation,需要原子性就寫在 before*

Endpoint

route + guards,回傳值即 response:

import { body, createEndpoint, reply } from 'directus-typed-kit/endpoint'

export const shareEndpoint = createEndpoint(({ route, knex }) => {
  // guard 回傳物件會 merge 進 ctx 且型別累加(同名欄位由 guard 覆寫):handler 拿到 typed ctx.link
  route.get('/:token', { guards: [loadLinkGuard] }, async ({ link }) => loadMetadata(knex, link))

  route.post('/:token/auth', { guards: [loadLinkGuard, body(AuthBody)] }, async ({ body, link }) => {
    if (!verifyPassword(body.password, link.password_hash))
      throw createError('UNAUTHORIZED', 'Invalid', 401)()
    return issueSession(link) // 一般值 → res.json(200)
  })

  route.delete('/:token', { guards: [loadLinkGuard] }, async ({ link }) => reply(204))
})

guard(body / query / params)與 hook 的 validate 都吃 Standard Schema,實作(zod / valibot / arktype)自選。

{ response: schema } 除了在 runtime 驗回應,也把 handler 回傳型別釘在 schema 的 output 上:形狀不符、reply() 的 body 不符、漏 return 都是編譯錯,不必等 runtime 撞 500。reply(status)(空 body)與 RAW(handler 自行寫 res、wrapper 不驗證也不序列化)不受此限。

guard ctx 與 handler ctx 一樣自帶存取器,故「查參與者」「驗擁有權」這類資料型授權可寫成可重用 guard、不必降級成 handler 內 helper:

export const loadParticipant: Guard<{ participant: Participant }> = async ({ knex, params, accountability }) => {
  const row = await knex('participants').where({ match: params.id, user: accountability?.user }).first()
  if (!row) throw new NotParticipantError()
  return { participant: row } // 型別累加進 handler ctx
}

資料存取

hook 與 endpoint 的 tools 都帶 Schema 綁定的存取器,全部 typed:items('files')services.XxxService()knex('files')(保留名如 FilesService / AssetsService 對應 Directus 內建特化 service)。

同一欄位在 service 與 knex 兩視角型別不同,各自對齊 runtime 實際回傳的值:

  • conceal 欄位(password / token…):service 讀取視角移除(讀出是遮蔽字串),要真值改走 knex寫入視角保留為純 string(建帳號、hook 內雜湊密碼都用得到)
  • 日期欄位:items / SDK 視角是 string,knex 視角是 Datetime 兩邊皆 string

fieldssort 都吃巢狀點記(folder.parent.namesort: ['-folder.name']),結果型別逐層推導。點記深度上限為三段——自參照關聯(parent: Folder)否則會無限遞迴,且候選 union 隨層數指數成長。

typed knex 的保障範圍是取回的 row 有型別row.nope 會報錯),不含查詢條件:where({ nope: 1 })select('nope') 走的是 knex QueryBuilder 自身的 fallback overload,未知欄位不會被擋。

services.XxxService() 的保留名對應 Directus 內建 service,優先於同名 collection。名單為 Assets / Files / Mail / Users / Roles / Folders / Permissions / Policies / Shares / Revisions / Activity / Settings / Notifications / Flows / Operations / Presets / Translations / Collections / Fields / Relations / Extensions——業務表撞名時該工廠取的是內建 service,其 CRUD 請改用 items(collection)(kit 偵測到會 warn 一次)。

transaction 開的交易同樣 Schema 綁定(原生 knex.transaction 的 trx 沒有這條簽章、整串查詢會塌成 any):

await transaction(async (trx) => {
  // trx 與 knex 同樣 typed,且體內的 items() / services / knex 自動落在同一交易
  const row = await trx('matches').where({ id }).forUpdate().first()
  await items('matches').updateOne(id, { state_seq: row!.state_seq + 1 })
})

knexitems() / services 一樣吃當前 scope 的交易(transaction() 開的、或 before* 事件的),不會另開一條連線去等自己鎖住的 row。

巢狀 transaction() 開的是 savepoint,在 before* hook 內呼叫則接在事件交易上。要綁到當前 scope 以外的交易(如把外部 trx 傳進無 scope 的 helper)才需要 items(c, { trx })

兩軸權限

| 軸線 | 問題 | 用法 | | --- | --- | --- | | 執行身分 | query 用什麼身分跑 | items(c, { as }) / service(Ctor, { as })as: 'admin' \| 'system' \| 'caller'(預設 caller) | | 授權 | 呼叫者准不准做 | definePermission(check, { message })(middleware,admin 與 system 自動放行) |

as: 'caller' 在沒有事件 / 請求 scope 時(schedule、或被丟出 async context 的 callback)退為匿名身分,不是 null——null 在 Directus 是 system、繞過全部 ACL。要 system 權限請顯式寫 { as: 'system' }

definePermission 只掛得上 before* / filter:after* 事件觸發時 mutation 已 commit,gate 丟的 ForbiddenError 擋不下任何東西(只會讓該 handler 不跑)。掛到 after* / action 是型別錯誤,cast 繞過的話註冊當下就 throw。

validate(schema) 的 parsed 結果會疊回原 payload 而非取代,故 schema 只列要驗的欄位即可,未列的不會被 zod / valibot 的預設 strip 洗掉。