conventions
GitHub定义buzzkit仓库的代码规范,涵盖函数命名契约、可读性风格及错误处理模式。提供正反示例以补充CLAUDE.md,指导代码编写与审查,确保API、包和仪表板的一致性。
Trigger Scenarios
Install
npx skills add buzzkit-dev/buzzkit --skill conventions -g -y
SKILL.md
Frontmatter
{
"name": "conventions",
"description": "buzzkit code conventions — load before writing or reviewing any code in this repo (API, packages, dashboard). Naming catalog, observability recipe, pagination\/response\/error patterns, readability style. Complements the terse rules in CLAUDE.md with the reasoning and right\/wrong examples."
}
buzzkit conventions
CLAUDE.md carries the law; this skill carries the worked examples. When they disagree, CLAUDE.md wins — and fix the drift in the same change.
Function verbs — one catalog, no synonyms
Domain functions (src/api/**) use exactly these verbs:
| Verb | Contract |
|---|---|
find* |
single row, throws NotFoundError when absent |
select* |
single row, returns null when absent (private lookups, queue paths) |
list* |
many rows (including sweep feeds) |
count* |
total for a paginated list |
create* / update* |
plain writes |
upsert* / register* / replace* |
idempotent writes |
softDelete* |
soft-delete + cascade effects |
revoke* (keys/invites) · remove* (memberships, actor rows) |
takes something away |
assert* |
invariant check that throws; returns void |
resolve* |
derive a value from input/context (settings, credentials, decrypted secrets) |
merge* · link* |
fold one subscriber's identity into another · attach another id to one (api/subscribers/merge.ts) |
serialize* |
response shape |
mask* · mark* |
redaction · response decorators |
| delivery verbs | enqueue* claim* apply* finalize* expire* reconcile* rewrap* purge* touch* revalidate* resend* accept* record* |
Banned everywhere: get*, fetch*, set*, load*, delete*, destroy*.
The find*/select* split is a contract callers rely on: if you null-check the result, the function must be select*; if absence is a 404, it must be find*. find* functions taking an id take the sqid string and decode inside (malformed id → 404, never 400).
Infrastructure-store exception: byte/KV stores speak read*/write*/delete*/insert* — libs/cache.ts (readCache/writeCache/deleteCache) and actor/store.ts. This vocabulary never leaks into src/api/**.
Name collisions across domains are bugs. api/deliveries (push) owns the unqualified delivery names; every other domain qualifies: serializeWebhookDelivery, listSourceDeliveries. If an import needs an alias, the name is wrong.
Readability style
- Never wrap a call chain in a ternary. Branch with guards and early returns; ternaries are for small value picks with simple operands.
// wrong
const [row] = sourceId ? await db.select().from(tables.source).where(...) : [];
// right
const sourceId = decodeEntityId('source', sourceSqid);
if (!sourceId) throw new NotFoundError('Source not found');
const [row] = await db.select().from(tables.source).where(...);
- Multi-line arrow bodies get a block and an explicit
return. One-liners stay as expressions.
// wrong
await trace('subscriptions.updateEnabled', async () =>
await db.update(tables.subscription).set({ enabled }).where(...).returning()
);
// right
await trace('subscriptions.updateEnabled', async () => {
return await db.update(tables.subscription).set({ enabled }).where(...).returning();
});
- Blank lines separate logical paragraphs inside a function — but only in functions long enough to have paragraphs. A short function (a guard + a return, a lookup + a return, a store accessor) has no internal blank lines at all; it reads as one unit. The break earns its place in longer bodies with real phases — the fetch, the transform, each loop, the return. This is a judgment convention, deliberately not a lint rule.
// right — short functions stay tight, even with a guard or a multi-line return
listUnflushed(limit: number): ActorEventRow[] {
const flushed = this.readFlushedSequence();
return this.sql<ActorEventRow>`
SELECT * FROM events WHERE sequence > ${flushed} ORDER BY sequence ASC LIMIT ${limit}
`;
}
No comments — anywhere, ever
Names and structure carry the meaning; invariants live in docs/. Applies to every package, JSDoc included. Only exceptions: functional directives (biome-ignore, @ts-expect-error), the route-table path comments in a modules/index.ts or modules/v1/index.ts (one /* /path */ above each .use(), always /*, never /**), and wrangler.jsonc commentary.
Observability — every unit of work is a span
- Wrap domain operations, provider calls, and queue/cron work in
trace('resource.verb', attrs?, fn). Span names are two segments, lowerCamelCase resource matching the module (credentials.replace,subscriptions.updateEnabled), with the two namespace familiesqueue.*andscheduler.*. The span name always mirrors the function it wraps. - Never interpolate into a span name. Provider, environment, and outcome are attributes:
trace('deliveries.send', { 'delivery.provider': provider }, …). - Stamp outcomes with
t.set()on any span whose result matters:delivery.ok,delivery.code, counts per outcome. A span with only a duration is half a span. - Provider sends always go through a
deliveries.sendspan withdelivery.ok/delivery.codestamped — including live activities and cancel pushes. - Logs are
log.info/warn/error('[Prefix] Sentence', fields)— neverconsole. Prefix is the area ([Engine],[Deliveries],[Webhooks],[Scheduler],[Actor],[Audit],[Error],[Queue]). Error logs always carryerror: describeError(error)plus every id in scope —tenantId,subscriberId,runId,workspaceId. A log line that can't be filtered per tenant is a defect. requestIdis automatic: the telemetry plugin records it per invocation, and both the response envelope and every log line pick it up. Don't thread it by hand.- Never swallow an error silently. A
catcheither rethrows, converts to a typed result, or logs with context — an emptycatch {}hides broken infrastructure behind normal-looking behavior. - Cron sweeps go through
sweep(name, run)(cron/sweep.ts); queue consumers throughconsume(name, batch, handler)(queue/consume.ts) — never hand-roll the span/db preamble. Sweeps return counts; they become span attributes.
The engine (Cloudflare Workflow)
- Each
context.dostep runs asrunInvocation(..., { traced: false })(the sharedtrace()is silent there) but emits a manualworkflow.step <name>span viarunWorkflowStep— all steps of a run share a deterministic trace derived from the run id, each linked to the triggering request's trace, andfinishemits theworkflow.run <slug>root span withworkflow.run.result. - Step code gets its db from
stepDb(), the tenant fromcontext.tenant(db)(memoized per wake), never its owncreateDb. - Step failures are recorded into run history (
report(step, 'failed', …)) — a run must never fail without its failing step being visible.
Database access
- Routes use the
dbfrom context (the shared plugin client). Engine/DO steps usestepDb(); queue consumers and sweeps usebatchDb(). Never callcreateDbwith inline options. - Soft delete only; every read filters
isNull(deletedAt). Every data-plane query filters bytenantIdfrom resolved auth context. - Counts go through
countRows(db, table, where)— never hand-rollselect({ total: count() })or rawsqlcount(*)``. - A write that spans more than one table is one
db.transaction, and helpers it calls takeDb | Tx(api/keys/index.ts). Durable Object and other out-of-database work cannot join that transaction, so it runs before it and must be idempotent — mark what it did in the actor (store.recordMergedFrom) and make a repeat a no-op, so a failed transaction is safely retried. Never leave a partial write behind by ordering the irreversible step last:api/subscribers/merge.tsmoves history and cancels runs first, then does every row change in one transaction.
Pagination — the domain owns the page
Model: listAuditEvents (api/audit/index.ts). The domain function takes (db, scope, options: { cursor?, limit?, …filters }), uses clampLimit + resolveCursor, fetches limit + 1 rows, returns toPage(...) (toPageBy for non-id cursors), optionally with total. The route is authorize → domain call → Response.page(page). Never assemble hasMore/nextCursor in a route file.
Responses & errors
- Always the envelope builders:
Response.success()/Response.list()/Response.page()/Response.error();markDeleted()on every DELETE. Rootidneeds{ entity: '…' }; new*Idfields need aFIELD_ENTITIESentry. - Throw the typed classes from
libs/error.tswith{ code, param }(lowercase snake_case codes). Never hand-build an error response in a handler. - Empty PATCH returns 200 with the unchanged entity (Stripe semantics), never 400.
Retries
One backoff engine: nextRetryDelaySeconds(policy, attemptsMade, { floorSeconds?, retryAfterSeconds? }) in libs/retry.ts. Each domain owns a distinctly named RetryPolicy (PUSH_RETRY_POLICY, WEBHOOK_RETRY_POLICY) — never re-export generic constant names from two policies. Every retry path has jitter, a cap, and honors Retry-After; webhook deliveries retry all non-2xx except 410 Gone.
Shared helpers — never re-implement these
Before writing a loop or utility, check whether it exists: timedFetch (libs/http.ts — timeout + latency + body excerpt, the only outbound-fetch shape); sealingContext + rewrapSealedRows (libs/crypto.ts); createVersioned/updateVersioned (api/versioning — the entity+version tables algorithm segments and workflows share); subscriberActorName (libs/actor.ts — the only place the actor key format exists); drain (utils/drain.ts — bounded page-drain loops); runConcurrently (utils/concurrency.ts — the one bounded fan-out: a worker pool over a shared index, used by provider sends, schedule starts, event ingest and imports; items start in order, the limit must be a positive integer, a failing item stops further items from starting, in-flight items finish, and the call then rejects with the first error, so nothing is still running when the caller sees it; catch per item when a bad item must not fail the batch); durationMs/lenientDurationSeconds (@buzzkit/schema/workflows); parseWallTime, DAY_MS, resolveTimeScale (libs/timezone.ts); countRows (libs/database.ts).
Where things live
modules/**— route files: nothing but the Elysia instance. No helpers, types, schemas, or serializers. Repeatedparamsshapes are named schemas in the domain orlibs/schemas.ts.src/api/<resource>— a directory of small scoped files structured likeapi/messages/:types.ts+schemas.ts+serialize.ts+constants.ts(only for real tunables) + concept files named for their concern + anindex.tsbarrel holding the primary queries/mutations and re-exporting every sibling. Importers always use the barrel; files inside the directory import each other directly (never the barrel — no self-cycles). Each file keeps the canonical internal order (types → constants → schemas → serializers → queries → mutations). Tiny single-concern resources may stay one leanindex.ts. Cross-resource algorithms get their own directory (api/scheduling,api/versioning). Unit tests mirror file for file:test/api/<resource>/<file>.test.ts↔src/api/<resource>/<file>.ts.src/libs— infrastructure (db, telemetry, response, retry, http, crypto, cache, timezone); a multi-concern lib becomes a directory (libs/auth/: client / resolution / handler / index-macros, imported aslibs/auth/index).src/utils— pure functions only.src/providers/<name>— a directory of scoped files (classify.ts,payload.ts,tokens.ts,validate.ts,send.ts) with an index barrel exporting theProviderDefinition;shared/holds cross-provider plumbing. Unit tests mirror file-for-file (test/providers/apns/send.test.ts).apps/webandapps/marketing(React/Astro): components PascalCase, hooks kebab-caseuse-<name>.tsx,lib/is.tsonly, and a directory never mixes.tsand.tsx— fold a component tree's types/constants/helpers into the.tsxthat owns them, and name files on import instead of adding a barrel to a components directory.- No single-use constants: a value gets a name only when reused or a tunable policy number.
Enforcement — what runs where
bun lint = Biome (hardened rule set; type-aware promise rules; custom Grit plugins in .biome/plugins/ banning awaited calls in ternaries and interpolated span names) + scripts/lint-conventions.ts (the comments ban and the verb catalog — things Grit cannot see). bunx knip catches cross-module dead exports/files/deps. Hooks: pre-commit runs Biome on staged files + conventions + sherif; commit-msg enforces conventional commits; pre-push runs check-types + unit tests. CI mirrors all of it plus the unit suites. Two known enforcement gaps to keep honest about: Grit snippet patterns miss calls with explicit type arguments (trace<T>(…)) — the conventions still apply there; and biome migrate on version bumps must be diff-reviewed (it has rewritten preset to "none" before, silently disabling every rule).
Version History
- 3d80715 Current 2026-09-11 11:08


