Skip to main content

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') with getRequestLogger(c)
  • Give every DrizzleD1Datasource / DrizzleD1TenantDatasource a subclass with readonly name
  • Rewrite scopedSelect(table, ...conditions) calls to the options object
  • Fix compile errors from ContentfulStatusCode, ApiResponse, BaseContextVariables and json* schema arguments
  • Decide whether you need timing: true in production
  • Check module order when route patterns overlap
  • Review what you put in ApiError details (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
  • orderBy accepts a column, an SQL expression (desc(orders.createdAt)) or an array of them.
  • limit and offset must be non-negative integers, and offset requires limit; otherwise a RangeError is thrown.
  • scopedSelect(table) with no conditions still works. The old scopedSelect(table, condition) form throws a TypeError instead 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-id header is no longer used.
  • cf-ray is used only if it matches ^[A-Za-z0-9-]{1,64}$.
  • A valid incoming X-Request-Id is 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

  • details are always in ErrorInfo (logs, customLogger, errorReporter). includeDetails now only controls what clients see. Keep secrets out of details: they reach reporters in production without redaction.
  • ErrorInfo.stack is the original throw site instead of the error handler's wrapper, and ErrorInfo.cause holds the serialized cause chain (at most 5 levels).
  • 5xx and critical errors are never throttled, and a N errors suppressed by error log throttling warning is logged after a throttle window.
  • Error logs use the request-scoped logger, so Server error / Client error lines carry requestId.
  • Non-Error throws (throw 'x') produce a 500 envelope and a log line instead of escaping app.fetch.
  • ApiError detection 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_code and authcode. To keep IP and user agent out of error context, set errors: {context: {includeClientInfo: false}}.

Logging

  • An invalid LOG_LEVEL (for example verbose) 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.err returns SerializedError with the cause chain; serializers.req accepts a HonoRequest (c.req) as well as a Request.
  • 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.

Reader0.1.x0.2.0
boolonly the string 'true' was truebooleans and true/false/1/0/yes/no/on/off (case-insensitive); anything else returns the default
int / floatNaN for unparsable valuesdefault
jsonthrew on an already-parsed object varreturns it as-is
arraythrew on an array varreturns it as-is
dateInvalid Datedefault
stringreturned non-string vars unchangedconverts them to strings
presenceown properties onlyreadable properties, excluding Object.prototype members; null counts as missing
Watch defaults of true

An 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, Set and bigint params changed (Map/Set used to serialize as {}). Existing entries for such params become misses once.
  • Circular params and class instances without toJSON() throw a TypeError from memoize / withCache.
  • CacheOptions is generic (CacheOptions<T>) and adds revive. KVCacheOptions adds logger. Cached values are JSON-only.
  • AbstractDatasource reads the KV binding named by the new overridable cacheBinding getter (default KV, 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

AreaAdditions
AppAppConfig.logger, AppConfig.requestLogging, requestId: {trustIncomingHeader}, ENDPOINT_METHODS, EndpointRegistry.register(method, path, handler, middleware?)
ContextContextResponseHelpers, ApiSuccessResponse, ApiErrorResponse, ApiResponseMeta, PaginationMeta.totalPages
OpenAPIPaginationMetaSchema, MetaSchema.traceId
ErrorsApiError.isApiError, ApiErrorJSON, ErrorInfo.cause, ErrorHandlerConfig.context.includeClientInfo
LoggingserializeError, SerializedError, SerializeErrorOptions, RequestLike, ResponseLike, resolveLogLevel, LOG_LEVELS
DatasourcesscopedCount, ScopedSelectOptions, ScopedOrderBy, cacheBinding
CacheCacheOptions.revive, KVCacheOptions.logger

See the changelog for the release summary and the API reference for every export.