Migrating from 0.1.x to 0.2.0
@ltv/cwb is pre-1.0, so a minor release may contain breaking changes (support policy). 0.2.0 is a production-readiness release. Most apps only need the required changes; the rest are behavior changes to review.
Checklist
- Replace
c.var.logger/c.get('logger')withgetRequestLogger(c) - Give every
DrizzleD1Datasource/DrizzleD1TenantDatasourcea subclass withreadonly name - Rewrite
scopedSelect(table, ...conditions)calls to the options object - Fix compile errors from
ContentfulStatusCode,ApiResponse,BaseContextVariablesandjson*schema arguments - Decide whether you need
timing: truein production - Check module order when route patterns overlap
- Review what you put in
ApiErrordetails(now always logged)
Required changes
1. Request logger variable renamed: logger to cwbLogger
The request-scoped logger moved to a namespaced variable, so it no longer clashes with your own logger context variable.
// Before (0.1.x)
c.var.logger?.info('hello');
c.get('logger')?.info('hello');
// After (0.2.0): preferred, falls back to the root logger outside createApp
import {getRequestLogger} from '@ltv/cwb';
getRequestLogger(c).info('hello');
// or, if you must read the variable directly
c.var.cwbLogger?.info('hello');
2. Drizzle datasources are abstract
DrizzleD1Datasource and DrizzleD1TenantDatasource no longer have default names ('drizzle-d1', 'drizzle-d1-tenant'). Two subclasses that kept the default shared one cache namespace and could return each other's cached data. Both classes are now abstract, and subclasses must declare name.
// Before (0.1.x)
const ds = new DrizzleD1TenantDatasource(c, {schema});
// After (0.2.0)
class OrderDatasource extends DrizzleD1TenantDatasource<typeof schema> {
readonly name = 'orders'; // unique: tags logs, prefixes the cache namespace
constructor(c: Context) {
super(c, {schema});
}
}
const ds = new OrderDatasource(c);
If a subclass relied on the old default name, its cache keys change; the old entries expire by TTL.
3. scopedSelect takes an options object
// Before (0.1.x): variadic conditions, no ordering or paging
await ds.scopedSelect(orders, eq(orders.status, 'open'), eq(orders.customerId, id));
await ds.db.select().from(orders).where(ds.where(orders)).orderBy(orders.id).limit(20);
// After (0.2.0)
import {and} from 'drizzle-orm';
await ds.scopedSelect(orders, {
where: and(eq(orders.status, 'open'), eq(orders.customerId, id)),
});
await ds.scopedSelect(orders, {orderBy: orders.id, limit: 20, offset: 40});
await ds.scopedCount(orders, eq(orders.status, 'open')); // new
orderByaccepts a column, an SQL expression (desc(orders.createdAt)) or an array of them.limitandoffsetmust be non-negative integers, andoffsetrequireslimit; otherwise aRangeErroris thrown.scopedSelect(table)with no conditions still works. The oldscopedSelect(table, condition)form throws aTypeErrorinstead of silently ignoring the condition.
4. Response helper status codes are ContentfulStatusCode
status parameters on c.respond, c.respondPaginated and c.respondError were number; they are now Hono's ContentfulStatusCode. The Workers runtime rejects a body on 204 and 304, so those statuses no longer compile.
// Before (0.1.x)
return c.respond(null, 204);
const status: number = 201;
return c.respond(data, status);
// After (0.2.0)
import type {ContentfulStatusCode} from 'hono/utils/http-status';
return c.respondNoContent();
const status: ContentfulStatusCode = 201;
return c.respond(data, status);
5. ApiResponse<T> is a discriminated union
ApiResponse<T> was one interface with success: boolean and optional data/error. It is now ApiSuccessResponse<T> | ApiErrorResponse. Narrow on success before reading data or error.
// Before (0.1.x)
const body = (await res.json()) as ApiResponse<User>;
console.log(body.data?.name, body.error?.code);
// After (0.2.0)
const body = (await res.json()) as ApiResponse<User>;
if (body.success) console.log(body.data.name);
else console.log(body.error.code);
ApiResponseMeta, ApiSuccessResponse and ApiErrorResponse are exported.
6. BaseContextVariables has no index signature
It no longer extends Record<string, unknown>, so c.get('anything') stops compiling on AppEnv<BaseContextVariables>. Declare your variables by extending the interface.
// Before (0.1.x): any key compiled, typos included
type Env = AppEnv<BaseContextVariables>;
c.get('orgId');
// After (0.2.0)
interface Vars extends BaseContextVariables {
orgId?: string;
}
type Env = AppEnv<Vars>;
c.get('orgId');
7. json* OpenAPI helpers require zod schemas
The schema parameter was unconstrained; it is now TSchema extends z.ZodType. Passing a non-zod value is a compile error.
// Before (0.1.x): compiled, produced a broken OpenAPI document
jsonSuccess('User', {id: 'string'});
// After (0.2.0)
jsonSuccess('User', z.object({id: z.string()}));
8. Pagination validation and totalPages
buildPaginationMeta, and therefore c.respondPaginated, throws a 400 BAD_REQUEST ApiError when page or limit is not a positive integer, or total is not a non-negative integer. It used to return misleading metadata (for example page=0 gave hasNext: true). meta.pagination gains totalPages.
// Before (0.1.x): query strings passed through unchecked
const page = Number(c.req.query('page'));
return c.respondPaginated(rows, {page, limit: 20, total});
// After (0.2.0): validate in the route schema so the helper never throws
request: {query: z.object({page: z.coerce.number().int().min(1).default(1)})},
OpenAPI: MetaSchema.pagination is now PaginationMetaSchema, with integer fields, a required totalPages and no .default() values. MetaSchema gains traceId. If you validate responses against MetaSchema, include totalPages.
9. Global logshipper type removed
The library no longer adds declare global { var logshipper } to your program. Runtime forwarding is unchanged: in production, log lines are still passed to globalThis.logshipper.log() when it exists. If your code references globalThis.logshipper, declare the type yourself.
declare global {
var logshipper: {log(data: unknown): void} | undefined;
}
10. ApiError.toJSON() omits stack
toJSON() returns ApiErrorJSON without stack, so JSON.stringify(error) never leaks it. The stack is still on error.stack, and ErrorInfo.stack still carries it to logs and reporters.
// Before (0.1.x)
reporter.send(error.toJSON()); // included stack
// After (0.2.0)
reporter.send({...error.toJSON(), stack: error.stack});
11. Route keys are validated
createApp throws at startup for a method other than GET, POST, PUT, PATCH or DELETE, and for two routes (in any modules) with the same method and final path. Previously an unsupported method failed with an opaque error and duplicates were registered silently. Remove duplicates or give the modules different versions or paths.
Behavior changes to review
Server-Timing is development-only
timing defaulted to true; it now defaults to isDev, because Server-Timing exposes handler durations. Keep the old behavior with createApp({timing: true, ...}).
Request ids
- The deprecated, client-spoofable
cf-request-idheader is no longer used. cf-rayis used only if it matches^[A-Za-z0-9-]{1,64}$.- A valid incoming
X-Request-Idis still reused. To always generate ids:createApp({requestId: {trustIncomingHeader: false}}).
Module middleware scope and route order
Module middleware was bound with app.on(method, path), so it also ran for another module's route whose path matched the same pattern (module A's GET /items/:id guard ran for module B's GET /items/public). It is now registered with each route handler and runs only for that module's routes.
With overlapping patterns, the first registered matching route handles the request. List the module with the more specific route first:
createApp({modules: [publicItems /* GET /items/public */, privateItems /* GET /items/:id */], openApi});
Errors: logs, reports and throttling
detailsare always inErrorInfo(logs,customLogger,errorReporter).includeDetailsnow only controls what clients see. Keep secrets out ofdetails: they reach reporters in production without redaction.ErrorInfo.stackis the original throw site instead of the error handler's wrapper, andErrorInfo.causeholds the serialized cause chain (at most 5 levels).- 5xx and
criticalerrors are never throttled, and aN errors suppressed by error log throttlingwarning is logged after a throttle window. - Error logs use the request-scoped logger, so
Server error/Client errorlines carryrequestId. - Non-Error throws (
throw 'x') produce a 500 envelope and a log line instead of escapingapp.fetch. ApiErrordetection uses a brand, so errors from a second installed copy of the library keep their 4xx status.ApiError.isApiError()works across copies.- More names are redacted: the fragments
hmac,private,cert,saml,assertion,auth_codeandauthcode. To keep IP and user agent out of error context, seterrors: {context: {includeClientInfo: false}}.
Logging
- An invalid
LOG_LEVEL(for exampleverbose) logs a warning and falls back to the default instead of failing at module load. Values are case-insensitive. - Logging never throws on
BigInt, circular objects or{req: c.req}. serializers.errreturnsSerializedErrorwith the cause chain;serializers.reqaccepts a HonoRequest (c.req) as well as aRequest.- Datasource and cache logs derive from the request logger and carry
requestId.
Environment readers
wrangler vars can be JSON values; the readers now accept them, and values that can't be converted fall back to the default.
| Reader | 0.1.x | 0.2.0 |
|---|---|---|
bool | only the string 'true' was true | booleans and true/false/1/0/yes/no/on/off (case-insensitive); anything else returns the default |
int / float | NaN for unparsable values | default |
json | threw on an already-parsed object var | returns it as-is |
array | threw on an array var | returns it as-is |
date | Invalid Date | default |
string | returned non-string vars unchanged | converts them to strings |
| presence | own properties only | readable properties, excluding Object.prototype members; null counts as missing |
trueAn unrecognized value now returns the default instead of false. With env.default.bool('FEATURE_X', true) and FEATURE_X="disabled", 0.1.x returned false but 0.2.0 returns true. Use false, 0, no or off to turn a flag off.
KV cache
- Cache keys for
Map,Setandbigintparams changed (Map/Setused to serialize as{}). Existing entries for such params become misses once. - Circular params and class instances without
toJSON()throw aTypeErrorfrommemoize/withCache. CacheOptionsis generic (CacheOptions<T>) and addsrevive.KVCacheOptionsaddslogger. Cached values are JSON-only.AbstractDatasourcereads the KV binding named by the new overridablecacheBindinggetter (defaultKV, unchanged).
respondNoContent keeps headers
It returned new Response(null, {status: 204}), which dropped X-Request-Id and headers set with c.header(). It now keeps them.
New APIs
| Area | Additions |
|---|---|
| App | AppConfig.logger, AppConfig.requestLogging, requestId: {trustIncomingHeader}, ENDPOINT_METHODS, EndpointRegistry.register(method, path, handler, middleware?) |
| Context | ContextResponseHelpers, ApiSuccessResponse, ApiErrorResponse, ApiResponseMeta, PaginationMeta.totalPages |
| OpenAPI | PaginationMetaSchema, MetaSchema.traceId |
| Errors | ApiError.isApiError, ApiErrorJSON, ErrorInfo.cause, ErrorHandlerConfig.context.includeClientInfo |
| Logging | serializeError, SerializedError, SerializeErrorOptions, RequestLike, ResponseLike, resolveLogLevel, LOG_LEVELS |
| Datasources | scopedCount, ScopedSelectOptions, ScopedOrderBy, cacheBinding |
| Cache | CacheOptions.revive, KVCacheOptions.logger |
See the changelog for the release summary and the API reference for every export.