@rdlabo/workers-hono-kit 0.10.2 → 0.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,14 +84,14 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
84
84
  | `createMaintenanceMiddleware(options)` / `createMaintenanceWaitHandler(options)` / `isMaintenanceEnabled(env)` / `MAINTENANCE_CODE` / `MAINTENANCE_WAIT_PATH` | Fleet maintenance short-circuit: when enabled (`MAINTENANCE=1`), every non-allowlisted request returns `503` + `{ statusCode, message, code: 'MAINTENANCE' }` **before** container/DB. Pair with `GET /public/maintenance/wait` SSE (`event: ping` / `event: ended`) so clients can auto-dismiss a lock UI. Mount after `cors`, before `containerMiddleware`. |
85
85
  | `ErrorReporter` / `ErrorReportContext` | Types for a `reportError`-style unhandled-error reporter (e.g. wired to Sentry), paired with `createHttpErrorHandler`'s `onUnhandledError`. |
86
86
  | `createSentryErrorReporter(sentry)` / `SentryExceptionReporterLike` | Build an `ErrorReporter` that forwards to Sentry with an optional `request_id` tag (no hard `@sentry/cloudflare` dependency). |
87
- | `DeferExecutor` / `defaultDefer` / `createWaitUntilDefer(ctx)` | Fire-and-forget executor for Workers: `defaultDefer` swallows rejections (tests); `createWaitUntilDefer` registers work via `ctx.waitUntil`. |
87
+ | `DeferExecutor` / `defaultDefer` / `createWaitUntilDefer(ctx)` | Fire-and-forget executor for Workers: both variants log background rejections without propagating them; `createWaitUntilDefer` also registers work via `ctx.waitUntil`. |
88
88
  | `configureHibernationAutoResponse` / `upgradeHibernationWebSocket` / `broadcastHibernationWebSockets` | Hibernation WebSocket room primitives: runtime ping/pong without waking JavaScript, attachment-before-accept upgrade, and broadcast through sockets restored by `getWebSockets()`. |
89
89
  | `acknowledgeHibernationWebSocketClose` / `closeHibernationWebSocket` | Safe close helpers, including normalization of reserved received-only close codes. |
90
90
  | `retryDurableObjectOperation(operation, options?)` / `isRetryableDurableObjectError(error)` | Retry idempotent DO work only for `retryable && !overloaded`, with jittered exponential backoff. `operation` runs per attempt so callers create a fresh stub after an exception. |
91
91
  | `createIdempotencyInput(...)` / `runIdempotentMutation(...)` | Canonical payload hashing and a transaction-bound mutation state machine. Missing keys preserve legacy behavior; replay/conflict/in-flight semantics are shared while each app owns its schema and ORM adapter. |
92
92
  | `withIdempotencyHttpErrors(run)` | Maps only standard idempotency failures to 400/409/503 and rethrows unrelated failures. |
93
93
  | `createAiGatewayProvider(config)` / `AiGatewayConfig` / `AiGatewayProvider` | Route `@ai-sdk` models through the Cloudflare AI Gateway, via either a Workers `AI` binding or REST credentials (`accountId` / `gateway` / `token`). |
94
- | `KVCache` / `KVNamespace` / `KVCacheOptions` | Workers-KV cache-aside helper (key `appName+version+table_type_column`, sha256 for string ids, TTL clamped ≥60s). Set `appName` / `version` per application. |
94
+ | `KVCache` / `KVNamespace` / `KVCacheOptions` / `KVCacheErrorContext` / `KVCacheOperation` | Workers-KV cache-aside helper (key `appName+version+table_type_column`, sha256 for string ids, TTL clamped ≥60s). Set `appName` / `version` per application; optional `onError(error, context)` observes fail-soft read/parse/serialize/write/delete failures. Context contains only the operation and logical table, not cache types, keys, ids, or values. |
95
95
  | `createStripeClient(secret, opts?)` / `verifyStripeWebhook(...)` / `CreateStripeClientOptions` | Workers-native Stripe client (fetch transport) + async webhook verification (SubtleCrypto). `apiVersion` optional (pin to a fixed Stripe API version). |
96
96
  | `extractStripeFailureReason(source)` / `StripeFailureReason` | Duck-type a Stripe `PaymentIntent` / `Invoice` / `{ paymentIntent?, invoice? }` / thrown error into a normalized `{ code, declineCode, message, paymentIntentId, invoiceId, subscriptionId }` (SDK-free), or `null`. |
97
97
  | `stripeFailureMessageJa(reason)` | Render a `StripeFailureReason` (or `null`) as a single user-facing Japanese sentence (`decline_code` > `code`; fraud codes masked; unknown → generic). |
@@ -626,6 +626,17 @@ await cache.set('users', 'byId', userId, user, 600);
626
626
  const hit = await cache.get<User>('users', 'byId', userId);
627
627
  ```
628
628
 
629
+ Cache failures remain fail-soft. To report them without changing caller behavior, configure the
630
+ optional observer (for example, to forward the raw error to Sentry). Kit-generated context is
631
+ limited to `operation` and `table`; it never adds the cache type, generated key, id, or value.
632
+
633
+ ```ts
634
+ const cache = new KVCache(env.CACHE, {
635
+ appName: 'myapp',
636
+ onError: (error, context) => reportError(error, context),
637
+ });
638
+ ```
639
+
629
640
  ### Stripe (Workers-native)
630
641
 
631
642
  ```ts
@@ -4,7 +4,9 @@
4
4
  * Provides a thin, JSON-serializing wrapper around a {@link KVNamespace} for the common
5
5
  * "look in cache, fall back to the source of truth" pattern. Reads and writes are best-effort:
6
6
  * any KV error, serialization failure, or oversized key is swallowed so callers transparently
7
- * fall through to their backing store instead of throwing.
7
+ * fall through to their backing store instead of throwing. An optional error reporter makes
8
+ * operational failures observable; kit-generated context omits cache types, keys, identifiers,
9
+ * and values.
8
10
  *
9
11
  * Cache keys are namespaced as `<appName><version><table>_<type>_<id>`, where a string `id` is
10
12
  * hashed with SHA-256 (hex) and a numeric `id` is used verbatim.
@@ -57,6 +59,15 @@ export interface KVNamespace {
57
59
  */
58
60
  delete(key: string): Promise<void>;
59
61
  }
62
+ /** The cache stage that failed while performing a fail-soft operation. */
63
+ export type KVCacheOperation = 'read' | 'parse' | 'serialize' | 'write' | 'delete';
64
+ /** Non-sensitive context supplied to {@link KVCacheOptions.onError}. */
65
+ export interface KVCacheErrorContext {
66
+ /** The cache stage that failed. */
67
+ operation: KVCacheOperation;
68
+ /** Logical table name; cache types, raw keys, identifiers, and values are intentionally omitted. */
69
+ table: string;
70
+ }
60
71
  /**
61
72
  * Configuration for a {@link KVCache} instance.
62
73
  */
@@ -81,6 +92,11 @@ export interface KVCacheOptions {
81
92
  * Defaults to `600`.
82
93
  */
83
94
  defaultLifetime?: number;
95
+ /**
96
+ * Optional synchronous observer for failures that the cache intentionally handles as misses.
97
+ * Reporter failures are isolated from cache callers and logged separately.
98
+ */
99
+ onError?: (error: unknown, context: KVCacheErrorContext) => void;
84
100
  }
85
101
  /**
86
102
  * A single entry to store via {@link KVCache.setMany}.
@@ -145,8 +161,8 @@ export declare class KVCache {
145
161
  * JSON-serialize and store a value.
146
162
  *
147
163
  * Falsy `data` is ignored. The effective TTL is `max(minTtlSeconds, lifetime ?? defaultLifetime)`,
148
- * honoring the KV 60-second floor. Oversized keys and serialization/write failures are silently
149
- * skipped.
164
+ * honoring the KV 60-second floor. Oversized keys are skipped; serialization/write failures are
165
+ * skipped and reported when an observer is configured.
150
166
  *
151
167
  * @param table - Logical table or entity name.
152
168
  * @param type - Sub-key discriminator.
@@ -198,7 +214,7 @@ export declare class KVCache {
198
214
  /**
199
215
  * Remove a cached entry.
200
216
  *
201
- * Oversized keys and delete failures are silently ignored.
217
+ * Oversized keys are ignored. Delete failures are reported when an observer is configured.
202
218
  *
203
219
  * @param table - Logical table or entity name.
204
220
  * @param type - Sub-key discriminator.
@@ -4,7 +4,9 @@
4
4
  * Provides a thin, JSON-serializing wrapper around a {@link KVNamespace} for the common
5
5
  * "look in cache, fall back to the source of truth" pattern. Reads and writes are best-effort:
6
6
  * any KV error, serialization failure, or oversized key is swallowed so callers transparently
7
- * fall through to their backing store instead of throwing.
7
+ * fall through to their backing store instead of throwing. An optional error reporter makes
8
+ * operational failures observable; kit-generated context omits cache types, keys, identifiers,
9
+ * and values.
8
10
  *
9
11
  * Cache keys are namespaced as `<appName><version><table>_<type>_<id>`, where a string `id` is
10
12
  * hashed with SHA-256 (hex) and a numeric `id` is used verbatim.
@@ -56,6 +58,7 @@ export class KVCache {
56
58
  #version;
57
59
  #minTtl;
58
60
  #defaultLifetime;
61
+ #onError;
59
62
  /**
60
63
  * Create a cache bound to a specific KV namespace.
61
64
  *
@@ -68,6 +71,16 @@ export class KVCache {
68
71
  this.#version = options.version ?? 'v8_';
69
72
  this.#minTtl = options.minTtlSeconds ?? 60;
70
73
  this.#defaultLifetime = options.defaultLifetime ?? 600;
74
+ this.#onError = options.onError;
75
+ }
76
+ /** Report an operational failure without letting observer code break the fail-soft contract. */
77
+ #reportError(error, context) {
78
+ try {
79
+ this.#onError?.(error, context);
80
+ }
81
+ catch (reporterError) {
82
+ console.error('[KVCache] error reporter failed', reporterError);
83
+ }
71
84
  }
72
85
  /**
73
86
  * Build the fully namespaced KV key for the given coordinates.
@@ -108,14 +121,22 @@ export class KVCache {
108
121
  if (!key) {
109
122
  return undefined;
110
123
  }
124
+ let data;
125
+ try {
126
+ data = await this.#kv.get(key);
127
+ }
128
+ catch (error) {
129
+ this.#reportError(error, { operation: 'read', table });
130
+ return undefined;
131
+ }
132
+ if (!data) {
133
+ return undefined;
134
+ }
111
135
  try {
112
- const data = await this.#kv.get(key);
113
- if (!data) {
114
- return undefined;
115
- }
116
136
  return JSON.parse(data);
117
137
  }
118
- catch {
138
+ catch (error) {
139
+ this.#reportError(error, { operation: 'parse', table });
119
140
  return undefined;
120
141
  }
121
142
  }
@@ -123,8 +144,8 @@ export class KVCache {
123
144
  * JSON-serialize and store a value.
124
145
  *
125
146
  * Falsy `data` is ignored. The effective TTL is `max(minTtlSeconds, lifetime ?? defaultLifetime)`,
126
- * honoring the KV 60-second floor. Oversized keys and serialization/write failures are silently
127
- * skipped.
147
+ * honoring the KV 60-second floor. Oversized keys are skipped; serialization/write failures are
148
+ * skipped and reported when an observer is configured.
128
149
  *
129
150
  * @param table - Logical table or entity name.
130
151
  * @param type - Sub-key discriminator.
@@ -149,11 +170,18 @@ export class KVCache {
149
170
  try {
150
171
  payload = JSON.stringify(data);
151
172
  }
152
- catch {
173
+ catch (error) {
174
+ this.#reportError(error, { operation: 'serialize', table });
175
+ return;
176
+ }
177
+ if (payload === undefined) {
178
+ this.#reportError(new TypeError('Cache value is not JSON-serializable'), { operation: 'serialize', table });
153
179
  return;
154
180
  }
155
181
  const ttl = Math.max(this.#minTtl, lifetime ?? this.#defaultLifetime);
156
- await this.#kv.put(key, payload, { expirationTtl: ttl }).catch(() => undefined);
182
+ await this.#kv.put(key, payload, { expirationTtl: ttl }).catch((error) => {
183
+ this.#reportError(error, { operation: 'write', table });
184
+ });
157
185
  }
158
186
  /**
159
187
  * Store many values concurrently.
@@ -199,7 +227,7 @@ export class KVCache {
199
227
  /**
200
228
  * Remove a cached entry.
201
229
  *
202
- * Oversized keys and delete failures are silently ignored.
230
+ * Oversized keys are ignored. Delete failures are reported when an observer is configured.
203
231
  *
204
232
  * @param table - Logical table or entity name.
205
233
  * @param type - Sub-key discriminator.
@@ -215,6 +243,8 @@ export class KVCache {
215
243
  if (!key) {
216
244
  return;
217
245
  }
218
- await this.#kv.delete(key).catch(() => undefined);
246
+ await this.#kv.delete(key).catch((error) => {
247
+ this.#reportError(error, { operation: 'delete', table });
248
+ });
219
249
  }
220
250
  }
@@ -9,8 +9,8 @@ import type { ExecutionContextLike } from './execution-context.js';
9
9
  */
10
10
  export type DeferExecutor = (promise: Promise<unknown>) => void;
11
11
  /**
12
- * Default defer implementation (NestJS `void promise` equivalent). Swallows rejections.
13
- * Used when no `ExecutionContext` is available (tests, partial scheduled paths).
12
+ * Default defer implementation (NestJS `void promise` equivalent). Reports rejections without
13
+ * propagating them. Used when no `ExecutionContext` is available (tests, partial scheduled paths).
14
14
  */
15
15
  export declare const defaultDefer: DeferExecutor;
16
16
  /**
@@ -1,9 +1,12 @@
1
+ const reportDeferFailure = (error) => {
2
+ console.error('[defer] background task failed', error);
3
+ };
1
4
  /**
2
- * Default defer implementation (NestJS `void promise` equivalent). Swallows rejections.
3
- * Used when no `ExecutionContext` is available (tests, partial scheduled paths).
5
+ * Default defer implementation (NestJS `void promise` equivalent). Reports rejections without
6
+ * propagating them. Used when no `ExecutionContext` is available (tests, partial scheduled paths).
4
7
  */
5
8
  export const defaultDefer = (promise) => {
6
- void promise.catch(() => undefined);
9
+ void promise.catch(reportDeferFailure);
7
10
  };
8
11
  /**
9
12
  * Build a {@link DeferExecutor} that keeps the worker alive until `promise` settles.
@@ -12,6 +15,6 @@ export const defaultDefer = (promise) => {
12
15
  */
13
16
  export function createWaitUntilDefer(ctx) {
14
17
  return (promise) => {
15
- ctx.waitUntil(promise.catch(() => undefined));
18
+ ctx.waitUntil(promise.catch(reportDeferFailure));
16
19
  };
17
20
  }
@@ -71,7 +71,7 @@ export interface HttpErrorHandlerOptions<E extends Env = Env> {
71
71
  isHttpError?: (err: unknown) => err is HttpErrorLike;
72
72
  /**
73
73
  * Hook invoked before an unexpected (non-HTTP) error is returned as a 500, typically used to report the
74
- * error (e.g. to Sentry). Any exception thrown by this hook is swallowed so reporting cannot alter the
74
+ * error (e.g. to Sentry). Any exception thrown by this hook is logged and kept from altering the
75
75
  * error response.
76
76
  */
77
77
  onUnhandledError?: (err: unknown, c: Context<E>) => void;
@@ -78,8 +78,10 @@ export function createHttpErrorHandler(options = {}) {
78
78
  try {
79
79
  onUnhandledError?.(err, c);
80
80
  }
81
- catch {
82
- // Reporting must never change the behavior of the error response.
81
+ catch (reportingError) {
82
+ // Reporting must never change the behavior of the error response, but its own failure must
83
+ // stay visible so a broken observability integration does not go unnoticed.
84
+ console.error('[httpError] onUnhandledError failed', reportingError);
83
85
  }
84
86
  if (findMysqlDriverError(err)) {
85
87
  logMysqlDriverError(err, 500);
@@ -40,8 +40,9 @@ export function createQueryFailedErrorHandler(options) {
40
40
  try {
41
41
  onUnhandledError?.(err, c);
42
42
  }
43
- catch {
44
- // Reporting must never change the error response.
43
+ catch (reportingError) {
44
+ // Reporting must never change the error response, but a broken reporter must remain visible.
45
+ console.error('[queryFailedError] onUnhandledError failed', reportingError);
45
46
  }
46
47
  }
47
48
  return c.json({ statusCode: classified.statusCode, message: classified.message }, classified.statusCode);
@@ -45,6 +45,49 @@ export type IdempotencyReservation<TResponse> = {
45
45
  kind: 'replay';
46
46
  response: TResponse;
47
47
  };
48
+ /** Locked persistence state for one leased idempotency key. */
49
+ export type LeasedIdempotencyRecord<TResponse> = {
50
+ /** The operation is currently owned by one processing token. */
51
+ state: 'processing';
52
+ /** Canonical payload hash stored with the key. */
53
+ payloadHash: string;
54
+ /** Token of the current processing owner. */
55
+ processingToken: string;
56
+ /** Whether the current owner's lease may be reclaimed. */
57
+ leaseExpired: boolean;
58
+ } | {
59
+ /** The operation completed and its response is replayable. */
60
+ state: 'completed';
61
+ /** Canonical payload hash stored with the key. */
62
+ payloadHash: string;
63
+ /** Persisted response returned to retries. */
64
+ response: TResponse;
65
+ };
66
+ /** Persistence primitives used by the shared leased-idempotency state machine. */
67
+ export interface LeasedIdempotencyStore<TScope extends IdempotencyScope, TResponse> {
68
+ /** Insert a processing row if the scoped key does not already exist. */
69
+ insertProcessing(input: IdempotencyInput<TScope>, processingToken: string): Promise<void>;
70
+ /** Lock and return the scoped key after the insert attempt. */
71
+ lock(input: IdempotencyInput<TScope>): Promise<LeasedIdempotencyRecord<TResponse>>;
72
+ /** Atomically replace an expired processing token and restart its lease. */
73
+ reclaim(input: IdempotencyInput<TScope>, previousToken: string, processingToken: string): Promise<boolean>;
74
+ /** Persist the response only while `processingToken` still owns the key. */
75
+ complete(input: IdempotencyInput<TScope>, processingToken: string, response: TResponse): Promise<boolean>;
76
+ /** Remove an uncommitted reservation only while `processingToken` still owns it. */
77
+ release(input: IdempotencyInput<TScope>, processingToken: string): Promise<void>;
78
+ }
79
+ /** Result of reserving a leased idempotency key. */
80
+ export type LeasedIdempotencyReservation<TResponse> = {
81
+ kind: 'acquired';
82
+ processingToken: string;
83
+ } | {
84
+ kind: 'replay';
85
+ response: TResponse;
86
+ };
87
+ /** Reserve, replay, or safely reclaim one leased idempotency key. */
88
+ export declare function reserveLeasedIdempotency<TScope extends IdempotencyScope, TResponse>(store: LeasedIdempotencyStore<TScope, TResponse>, input: IdempotencyInput<TScope>): Promise<LeasedIdempotencyReservation<TResponse>>;
89
+ /** Complete a leased idempotent operation without accepting a stale owner. */
90
+ export declare function completeLeasedIdempotency<TScope extends IdempotencyScope, TResponse>(store: LeasedIdempotencyStore<TScope, TResponse>, input: IdempotencyInput<TScope>, processingToken: string, response: TResponse): Promise<void>;
48
91
  /** Transaction-bound persistence operations required by {@link runIdempotentMutation}. */
49
92
  export interface IdempotentMutationStore<TScope extends IdempotencyScope, TResponse> {
50
93
  /** Atomically reserve a key or return its previously completed response. */
@@ -27,6 +27,34 @@ export class IdempotencyInFlightError extends Error {
27
27
  this.name = 'IdempotencyInFlightError';
28
28
  }
29
29
  }
30
+ /** Reserve, replay, or safely reclaim one leased idempotency key. */
31
+ export async function reserveLeasedIdempotency(store, input) {
32
+ const processingToken = crypto.randomUUID();
33
+ await store.insertProcessing(input, processingToken);
34
+ const record = await store.lock(input);
35
+ if (record.payloadHash !== input.payloadHash) {
36
+ throw new IdempotencyConflictError();
37
+ }
38
+ if (record.state === 'completed') {
39
+ return { kind: 'replay', response: record.response };
40
+ }
41
+ if (record.processingToken === processingToken) {
42
+ return { kind: 'acquired', processingToken };
43
+ }
44
+ if (!record.leaseExpired) {
45
+ throw new IdempotencyInFlightError();
46
+ }
47
+ if (!(await store.reclaim(input, record.processingToken, processingToken))) {
48
+ throw new IdempotencyInFlightError('Idempotent request ownership changed during lease reclaim');
49
+ }
50
+ return { kind: 'acquired', processingToken };
51
+ }
52
+ /** Complete a leased idempotent operation without accepting a stale owner. */
53
+ export async function completeLeasedIdempotency(store, input, processingToken, response) {
54
+ if (!(await store.complete(input, processingToken, response))) {
55
+ throw new IdempotencyInFlightError('Idempotent request ownership was lost before completion');
56
+ }
57
+ }
30
58
  /** Deterministically serialize JSON data with locale-independent, UTF-16 code-unit key ordering. */
31
59
  export function canonicalJson(value) {
32
60
  try {
package/dist/index.d.ts CHANGED
@@ -49,8 +49,8 @@ export { createSentryErrorReporter } from './http/http-error.js';
49
49
  export type { SentryExceptionReporterLike } from './http/http-error.js';
50
50
  export { AUTH_FAILURE_SCOPES, AUTH_IDENTITY_INVALID_CODE, createAuthFailureBody, createIdentityAuthFailureBody, createLegacyIdentityAuthFailureBody, } from './http/auth-failure.js';
51
51
  export type { AuthFailureBody, AuthFailureScope } from './http/auth-failure.js';
52
- export { canonicalJson, createIdempotencyInput, IdempotencyConflictError, IdempotencyInFlightError, IdempotencyKeyValidationError, IdempotencyPayloadValidationError, runIdempotentMutation, sha256CanonicalJson, withIdempotencyHttpErrors, } from './idempotency/idempotency.js';
53
- export type { CreateIdempotencyInputOptions, IdempotencyInput, IdempotencyReservation, IdempotencyScope, IdempotencyScopeValue, IdempotentMutationStore, } from './idempotency/idempotency.js';
52
+ export { canonicalJson, completeLeasedIdempotency, createIdempotencyInput, IdempotencyConflictError, IdempotencyInFlightError, IdempotencyKeyValidationError, IdempotencyPayloadValidationError, runIdempotentMutation, reserveLeasedIdempotency, sha256CanonicalJson, withIdempotencyHttpErrors, } from './idempotency/idempotency.js';
53
+ export type { CreateIdempotencyInputOptions, IdempotencyInput, LeasedIdempotencyRecord, LeasedIdempotencyReservation, LeasedIdempotencyStore, IdempotencyReservation, IdempotencyScope, IdempotencyScopeValue, IdempotentMutationStore, } from './idempotency/idempotency.js';
54
54
  export { acknowledgeHibernationWebSocketClose, broadcastHibernationWebSockets, closeHibernationWebSocket, configureHibernationAutoResponse, upgradeHibernationWebSocket, } from './realtime/hibernation.js';
55
55
  export type { HibernationAutoResponseOptions, HibernationUpgradeOptions, HibernationWebSocketLike, HibernationWebSocketStateLike, WebSocketAutoResponsePairFactory, WebSocketPairFactory, } from './realtime/hibernation.js';
56
56
  export { isRetryableDurableObjectError, retryDurableObjectOperation } from './realtime/retry.js';
@@ -60,7 +60,7 @@ export type { DurableObjectFetchRequest, DurableObjectFetchStubLike, InvokeDurab
60
60
  export { parseRealtimeWebSocketProtocolOffer, parseWebSocketProtocols } from './realtime/protocol.js';
61
61
  export type { ParseRealtimeWebSocketProtocolOptions, RealtimeWebSocketProtocolOffer } from './realtime/protocol.js';
62
62
  export { KVCache } from './cache/kv-cache.js';
63
- export type { KVNamespace, KVCacheOptions } from './cache/kv-cache.js';
63
+ export type { KVNamespace, KVCacheErrorContext, KVCacheOperation, KVCacheOptions } from './cache/kv-cache.js';
64
64
  export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
65
65
  export type { CreateStripeClientOptions } from './stripe/client.js';
66
66
  export { extractStripeFailureReason, stripeFailureMessageJa, serializePaymentFailure, serializeIapFailureReason, parsePaymentFailure, PaymentDeclinedError, toPaymentDeclinedError, } from './stripe/failure.js';
package/dist/index.js CHANGED
@@ -37,7 +37,7 @@ export { defaultDefer, createWaitUntilDefer } from './http/defer.js';
37
37
  export { createSentryErrorReporter } from './http/http-error.js';
38
38
  export { AUTH_FAILURE_SCOPES, AUTH_IDENTITY_INVALID_CODE, createAuthFailureBody, createIdentityAuthFailureBody, createLegacyIdentityAuthFailureBody, } from './http/auth-failure.js';
39
39
  // idempotency
40
- export { canonicalJson, createIdempotencyInput, IdempotencyConflictError, IdempotencyInFlightError, IdempotencyKeyValidationError, IdempotencyPayloadValidationError, runIdempotentMutation, sha256CanonicalJson, withIdempotencyHttpErrors, } from './idempotency/idempotency.js';
40
+ export { canonicalJson, completeLeasedIdempotency, createIdempotencyInput, IdempotencyConflictError, IdempotencyInFlightError, IdempotencyKeyValidationError, IdempotencyPayloadValidationError, runIdempotentMutation, reserveLeasedIdempotency, sha256CanonicalJson, withIdempotencyHttpErrors, } from './idempotency/idempotency.js';
41
41
  // realtime
42
42
  export { acknowledgeHibernationWebSocketClose, broadcastHibernationWebSockets, closeHibernationWebSocket, configureHibernationAutoResponse, upgradeHibernationWebSocket, } from './realtime/hibernation.js';
43
43
  export { isRetryableDurableObjectError, retryDurableObjectOperation } from './realtime/retry.js';
@@ -23,8 +23,8 @@ export interface ValidateOptions {
23
23
  *
24
24
  * @remarks
25
25
  * This never changes validation behavior — the response is always a NestJS `ValidationPipe`-shaped
26
- * 400. Exceptions thrown by the hook are swallowed. The default is a no-op (4xx errors are not
27
- * reported); pass a hook to forward failures to an error tracker.
26
+ * 400. Exceptions thrown by the hook are logged without changing the response. The default is a
27
+ * no-op (4xx errors are not reported); pass a hook to forward failures to an error tracker.
28
28
  *
29
29
  * @param error - The zod error describing the failed validation.
30
30
  * @param c - The Hono context for the failing request.
@@ -55,8 +55,9 @@ export function validate(target, schema, options) {
55
55
  try {
56
56
  options?.onValidationError?.(result.error, c);
57
57
  }
58
- catch {
59
- // Reporting must never change validation error behavior.
58
+ catch (reportingError) {
59
+ // Reporting must never change validation behavior, but a broken reporter must remain visible.
60
+ console.error('[validation] onValidationError failed', reportingError);
60
61
  }
61
62
  return c.json({ statusCode: 400, message: messages, error: 'Bad Request' }, 400);
62
63
  }
@@ -12,3 +12,5 @@ export { defineRestDbMethodConverter } from './rest-db-method-converter.js';
12
12
  export type { CompleteRestDbTableScheme, RestDbMethodConverter } from './rest-db-method-converter.js';
13
13
  export { decodeOfflineSnapshotCursor, encodeOfflineSnapshotCursor } from './snapshot-cursor.js';
14
14
  export type { OfflineSnapshotCursor } from './snapshot-cursor.js';
15
+ export { assertOfflineJournalCursorRetained, compactOfflineJournal, OfflineJournalRebaselineRequiredError, } from './journal-retention.js';
16
+ export type { CompactOfflineJournalOptions, OfflineJournalRetentionCandidate, OfflineJournalRetentionStore, OfflineJournalRetentionTransaction, } from './journal-retention.js';
@@ -10,3 +10,4 @@ export { fromTinyIntFlag, replicaTimestampMs, toReplicaDateOnly, toReplicaIsoDat
10
10
  export { replicaNowIso } from './clock.js';
11
11
  export { defineRestDbMethodConverter } from './rest-db-method-converter.js';
12
12
  export { decodeOfflineSnapshotCursor, encodeOfflineSnapshotCursor } from './snapshot-cursor.js';
13
+ export { assertOfflineJournalCursorRetained, compactOfflineJournal, OfflineJournalRebaselineRequiredError, } from './journal-retention.js';
@@ -0,0 +1,49 @@
1
+ /** One retained product journal row considered for bounded cleanup. */
2
+ export interface OfflineJournalRetentionCandidate<TScope> {
3
+ /** Monotonic journal position that becomes a client delta cursor. */
4
+ readonly cursor: number;
5
+ /** Product-defined authorization and retention partition. */
6
+ readonly scope: TScope;
7
+ }
8
+ /** Storage operations that must share one database transaction. */
9
+ export interface OfflineJournalRetentionTransaction<TScope> {
10
+ /** Selects the oldest bounded cleanup candidates. */
11
+ listCandidates(cutoff: Date, limit: number): Promise<readonly OfflineJournalRetentionCandidate<TScope>[]>;
12
+ /** Locks existing scopes against concurrent pull floor validation. */
13
+ lockScopes(scopes: readonly TScope[]): Promise<readonly TScope[]>;
14
+ /** Monotonically advances each scope's retained-history floor. */
15
+ advanceFloors(floors: readonly {
16
+ scope: TScope;
17
+ cursor: number;
18
+ }[]): Promise<void>;
19
+ /** Deletes the complete candidate set, including rows for scopes that no longer exist. */
20
+ deleteCandidates(cursors: readonly number[]): Promise<void>;
21
+ }
22
+ /** Product adapter that owns schema-specific persistence and transaction creation. */
23
+ export interface OfflineJournalRetentionStore<TScope> {
24
+ /** Runs the complete retention state machine in one database transaction. */
25
+ transaction<T>(operation: (tx: OfflineJournalRetentionTransaction<TScope>) => Promise<T>): Promise<T>;
26
+ }
27
+ /** Inputs for one bounded offline journal compaction pass. */
28
+ export interface CompactOfflineJournalOptions<TScope> {
29
+ /** Product adapter that owns persistence and transaction creation. */
30
+ readonly store: OfflineJournalRetentionStore<TScope>;
31
+ /** Exclusive upper time boundary of journal rows eligible for cleanup. */
32
+ readonly cutoff: Date;
33
+ /** Maximum number of journal rows compacted in one pass. */
34
+ readonly limit: number;
35
+ /** Canonical identity used to deduplicate and compare product scopes. */
36
+ readonly scopeKey: (scope: TScope) => string;
37
+ }
38
+ /** Raised when a delta cursor predates the retained journal and must restart from a snapshot. */
39
+ export declare class OfflineJournalRebaselineRequiredError extends Error {
40
+ }
41
+ /** Fail closed before reading deltas whose tombstones may already have been compacted. */
42
+ export declare function assertOfflineJournalCursorRetained(cursor: number, floor: number): void;
43
+ /**
44
+ * Advances retention floors and deletes one bounded journal batch atomically.
45
+ *
46
+ * Product adapters provide schema-specific queries. This state machine owns the
47
+ * safety order: candidates -> ordered opposing locks -> floors -> deletion.
48
+ */
49
+ export declare function compactOfflineJournal<TScope>(options: CompactOfflineJournalOptions<TScope>): Promise<number>;
@@ -0,0 +1,68 @@
1
+ /** Raised when a delta cursor predates the retained journal and must restart from a snapshot. */
2
+ export class OfflineJournalRebaselineRequiredError extends Error {
3
+ }
4
+ /** Fail closed before reading deltas whose tombstones may already have been compacted. */
5
+ export function assertOfflineJournalCursorRetained(cursor, floor) {
6
+ if (!Number.isSafeInteger(cursor) || cursor < 0 || !Number.isSafeInteger(floor) || floor < 0) {
7
+ throw new RangeError('Offline journal cursor and retention floor must be non-negative safe integers.');
8
+ }
9
+ if (cursor < floor) {
10
+ throw new OfflineJournalRebaselineRequiredError('Offline journal cursor predates retained history.');
11
+ }
12
+ }
13
+ /**
14
+ * Advances retention floors and deletes one bounded journal batch atomically.
15
+ *
16
+ * Product adapters provide schema-specific queries. This state machine owns the
17
+ * safety order: candidates -> ordered opposing locks -> floors -> deletion.
18
+ */
19
+ export function compactOfflineJournal(options) {
20
+ if (!Number.isSafeInteger(options.limit) || options.limit <= 0) {
21
+ throw new RangeError('Offline journal retention limit must be a positive safe integer.');
22
+ }
23
+ if (Number.isNaN(options.cutoff.getTime())) {
24
+ throw new RangeError('Offline journal retention cutoff must be a valid date.');
25
+ }
26
+ return options.store.transaction(async (tx) => {
27
+ const candidates = await tx.listCandidates(options.cutoff, options.limit);
28
+ if (candidates.length === 0) {
29
+ return 0;
30
+ }
31
+ assertCandidates(candidates);
32
+ const scopeByKey = new Map(candidates.map((candidate) => [options.scopeKey(candidate.scope), candidate.scope]));
33
+ const scopes = [...scopeByKey.entries()]
34
+ .sort(([left], [right]) => compareCanonicalKeys(left, right))
35
+ .map(([, scope]) => scope);
36
+ const lockedKeys = new Set((await tx.lockScopes(scopes)).map(options.scopeKey));
37
+ const floorByKey = new Map();
38
+ for (const candidate of candidates) {
39
+ const key = options.scopeKey(candidate.scope);
40
+ if (!lockedKeys.has(key)) {
41
+ continue;
42
+ }
43
+ const current = floorByKey.get(key);
44
+ if (!current || candidate.cursor > current.cursor) {
45
+ floorByKey.set(key, { scope: candidate.scope, cursor: candidate.cursor });
46
+ }
47
+ }
48
+ if (floorByKey.size > 0) {
49
+ await tx.advanceFloors([...floorByKey.entries()]
50
+ .sort(([left], [right]) => compareCanonicalKeys(left, right))
51
+ .map(([, floor]) => floor));
52
+ }
53
+ await tx.deleteCandidates(candidates.map((candidate) => candidate.cursor));
54
+ return candidates.length;
55
+ });
56
+ }
57
+ function compareCanonicalKeys(left, right) {
58
+ return left < right ? -1 : left > right ? 1 : 0;
59
+ }
60
+ function assertCandidates(candidates) {
61
+ const cursors = new Set();
62
+ for (const candidate of candidates) {
63
+ if (!Number.isSafeInteger(candidate.cursor) || candidate.cursor <= 0 || cursors.has(candidate.cursor)) {
64
+ throw new Error('Offline journal retention candidates must have unique positive safe-integer cursors.');
65
+ }
66
+ cursors.add(candidate.cursor);
67
+ }
68
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"