@rdlabo/workers-hono-kit 0.6.10 → 0.6.13

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
@@ -2,15 +2,16 @@
2
2
 
3
3
  Infrastructure toolkit for building APIs on [Hono](https://hono.dev) + [Cloudflare Workers](https://workers.cloudflare.com).
4
4
 
5
- It provides the building blocks a NestJS-style API needs but that don't run on `workerd` (no Node.js AWS SDK, no `firebase-admin`), plus middleware that matches Express / NestJS response semantics byte-for-byte:
5
+ It provides the building blocks a NestJS-style API needs but that don't run on `workerd` (no Node.js AWS SDK, no `firebase-admin`), plus middleware for common HTTP response concerns:
6
6
 
7
7
  - **Firebase ID-token verification** on Workers via [`jose`](https://github.com/panva/jose) (RS256 against Google's securetoken JWKS), with optional Identity Toolkit REST for `getUser` / `deleteUser`.
8
8
  - **AWS Secrets Manager / STS AssumeRole / CloudFront signed URLs** via SigV4-signed `fetch` ([`aws4fetch`](https://github.com/mhart/aws4fetch)) or Web Crypto — no AWS SDK.
9
- - **Middleware**: `finalizeResponse` (Express-compatible weak ETag + JSON charset), `validate` (NestJS `ValidationPipe`-shaped 400), and zod number-coercion helpers.
9
+ - **Middleware**: `finalizeResponse` (weak ETag via `hono/etag`), `validate` (NestJS `ValidationPipe`-shaped 400), and zod number-coercion helpers.
10
10
  - **Standard API errors**: `createHttpErrorHandler` / `notFoundHandler` / `HttpStatus`.
11
11
  - **Deadlock retry** (`ER_LOCK_DEADLOCK` exponential backoff) and an optional **MySQL data layer** (`@rdlabo/workers-hono-kit/db`) for Hyperdrive + Drizzle.
12
12
  - **AI Gateway**: route `@ai-sdk` models through the Cloudflare AI Gateway.
13
13
  - **Stripe** Workers-native client + async webhook verification.
14
+ - **Payment failure & subscription reconcile**: provider-agnostic `payment_failed` helpers — Stripe decline reasons → Japanese messages, Apple / Google subscription-renewal classification, `iapFailureKey` / receipt (de)serialization, and Stripe reconcile branch decisions.
14
15
  - **Testing helpers** (`@rdlabo/workers-hono-kit/testing`): a Drizzle-migration-backed test database, in-memory Firebase fake, configurable test doubles, and Stripe fixtures.
15
16
 
16
17
  ## Install
@@ -50,7 +51,7 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
50
51
 
51
52
  | Export | Description |
52
53
  | --- | --- |
53
- | `finalizeResponse()` | Middleware that adds an Express-compatible weak `ETag` and JSON `charset=utf-8`. |
54
+ | `finalizeResponse()` | Middleware that adds a weak `ETag` (delegates to `hono/etag`; also handles `If-None-Match` → `304`). |
54
55
  | `validate(target, schema, options?)` | Zod validator → NestJS `ValidationPipe`-shaped `400` (`{ statusCode, message[], error }`). `options.onValidationError(err, c)` to report (e.g. Sentry). |
55
56
  | `createValidate({ sentry? })` | Bound `validate` factory. Pass `sentry` on Sentry apps; omit for console-only (review, cbs-ai). |
56
57
  | `createSentryValidate(sentry)` | **Deprecated** — use `createValidate({ sentry })`. |
@@ -66,7 +67,7 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
66
67
  | `getUserProtocol(c)` / `IUserProtocol` | Read client IP / UA (`CF-Connecting-IP` → `X-Forwarded-For`). |
67
68
  | `getAppInfo(c)` / `AppInfo` | Read `x-amz-meta-version` / `x-amz-meta-uuid`. |
68
69
  | `resolveAppEnv(env)` / `isProductionEnv(env)` / `AppEnv` | Resolve `'development'` / `'production'` from `env.APP_ENV` (defaults to `'production'` for safety). |
69
- | `HttpStatus` | HTTP status enum identical to NestJS `@nestjs/common`. |
70
+ | `HttpStatus` | Standard HTTP status code enum (IANA registry). |
70
71
  | `createHttpErrorHandler(options?)` / `HttpErrorHandlerOptions` | `app.onError()` handler that maps a thrown `HTTPException` to `{ statusCode, message, error? }` (`401` omits `error`). Optional custom error predicate and unhandled-error report hook. Unhandled errors log via `console.error` (mysql2 errors include `sqlMessage` / `errno` when detectable). |
71
72
  | `createAppErrorHandler(options?)` / `CreateAppErrorHandlerOptions` | Standard `app.onError`: {@link createQueryFailedErrorHandler} + default {@link classifyGenericMysqlDriverError} + optional `sentry` (Sentry apps), `getReportError` / `reportError` (tests / container), or neither (no external reporting). |
72
73
  | `createQueryFailedErrorHandler(options)` / `QueryFailedClassifier` / `ClassifiedDbError` | Lower-level compose when you need full control over `classify` + `onUnhandledError` without defaults. |
@@ -83,6 +84,16 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
83
84
  | `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`). |
84
85
  | `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. |
85
86
  | `createStripeClient(secret, opts?)` / `verifyStripeWebhook(...)` / `CreateStripeClientOptions` | Workers-native Stripe client (fetch transport) + async webhook verification (SubtleCrypto). `apiVersion` optional (pin to a fixed Stripe API version). |
87
+ | `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`. |
88
+ | `stripeFailureMessageJa(reason)` | Render a `StripeFailureReason` (or `null`) as a single user-facing Japanese sentence (`decline_code` > `code`; fraud codes masked; unknown → generic). |
89
+ | `PaymentDeclinedError` / `toPaymentDeclinedError(error, status?)` / `PaymentDeclinedBody` | `HTTPException` carrying a verbatim `{ statusCode, message, code?, declineCode? }` body for a synchronous card decline (defaults to `400`). `toPaymentDeclinedError` returns `null` for non-declines (re-throw → 500). |
90
+ | `classifyStripeReconcile(subscription)` / `StripeReconcileAction` | Classify an expanded Stripe subscription into `trial` / `clear` / `canceled` / `failed` / `action_required` / `none` (termination evaluated before `succeeded`). Consumer does the DB write. |
91
+ | `serializePaymentFailure(record)` / `parsePaymentFailure(receipt)` / `PaymentFailureRecord` / `PaymentFailureReason` / `PaymentFailureSource` | (De)serialize the `payment_failed.receipt` JSON. `parsePaymentFailure` restores both a full Stripe record and a bare IAP reason. |
92
+ | `serializeIapFailureReason(reason)` / `IapFailureReason` | Serialize an IAP reason (`billing_retry` / `auto_renew_off` / `subscription_canceled` / `subscription_gone` + provider codes) directly, without the source/timestamp wrapper. |
93
+ | `paymentFailureMessageJa(input)` / `PaymentFailureStatus` / `PaymentFailureType` / `UNRESOLVED_PAYMENT_STATUSES` | Provider-agnostic Japanese message for a `payment_failed` row (`canceled` re-subscribe prompt, IAP `failed` App Store/Google Play prompt, else Stripe wording). `UNRESOLVED_PAYMENT_STATUSES` = everything except `resolved` for read/resolve `WHERE`. |
94
+ | `iapFailureKey(input)` | Provider-native `payment_failed.recursions_id`: iOS `${original_transaction_id}:${expires_date_ms}`, Android `${orderId}` (provider is in the `type` column). |
95
+ | `verifyAppleReceipt(receipt, opts)` / `classifyAppleRenewal(verify, now)` / `AppleRenewalClassification` / `AppleRenewalState` / `AppleVerifyReceiptResponse` / `ApplePendingRenewalInfo` / `AppleLatestReceiptInfo` | Verify an App Store receipt (production → sandbox fallback; inject `password` / `fetchImpl`) and classify it into `billing_retry` / `lapsed` / `active` / `unknown` plus the raw fields used (`statusCode` / `billingRetryStatus` / `autoRenewStatus`, latest `original_transaction_id` / `expires_date_ms`). |
96
+ | `googleAccessToken(creds, fetch?)` / `getGoogleSubscription(opts)` / `classifyGoogleSubscription(purchase, now)` / `GoogleSubscriptionClassification` / `GoogleSubscriptionState` / `GoogleSubscriptionPurchase` / `GoogleOAuthCredentials` | Exchange a refresh token for an Android Publisher access token (throws on `invalid_grant`), fetch a subscription purchase, and classify it into `canceled` / `gone` / `active` / `unknown` plus raw `statusCode` / `cancelReason`. |
86
97
  | `sendInChunks(queue, messages, options?)` / `QueueLike` / `QueueSendMessage` | Send queue messages in bounded chunks to stay under the Workers subrequest cap per invocation. `options.chunkSize` sets the per-batch size (defaults to and is capped at 100). |
87
98
  | `processBatch(batch, handler, options?)` / `MessageBatchLike` / `QueueMessageLike` / `ProcessBatchOptions` / `ProcessBatchResult` | Process a queue batch with bounded concurrency (consumer-side counterpart to `sendInChunks`). |
88
99
  | `createQueueErrorHandler(options)` / `CreateQueueErrorHandlerOptions` | Factory for `processBatch`'s `onError`: logs every failure; optional Sentry capture with queue/message context; optional `maxRetries` gate (report only on final attempt). |
@@ -195,7 +206,7 @@ Requires the `drizzle-orm` and `mysql2` peers. Consolidates duplicated test boil
195
206
 
196
207
  ## Usage
197
208
 
198
- ### Response finalization (ETag / charset)
209
+ ### Response finalization (ETag)
199
210
 
200
211
  ```ts
201
212
  import { Hono } from 'hono';
@@ -504,6 +515,62 @@ const stripe = createStripeClient(secret); // or { apiVersion: '2024-04-10' } to
504
515
  const event = await verifyStripeWebhook(secret, webhookSecret, rawBody, c.req.header('stripe-signature') ?? '');
505
516
  ```
506
517
 
518
+ ### Payment failure & subscription reconcile
519
+
520
+ Store only the raw reason; render the user-facing message on read (so wording changes never need a migration).
521
+
522
+ ```ts
523
+ import {
524
+ extractStripeFailureReason,
525
+ serializePaymentFailure,
526
+ paymentFailureMessageJa,
527
+ } from '@rdlabo/workers-hono-kit';
528
+
529
+ // On a Stripe failure webhook: persist the normalized reason.
530
+ const reason = extractStripeFailureReason(event.data.object);
531
+ if (reason) {
532
+ await db.write.insert(paymentFailed).values({
533
+ type: 'stripe',
534
+ status: 'failed',
535
+ receipt: serializePaymentFailure({ reason, source: 'webhook.invoice.payment_failed', occurredAt }),
536
+ });
537
+ }
538
+
539
+ // On read: provider-agnostic Japanese message.
540
+ const message = paymentFailureMessageJa({ status: row.status, type: row.type, reason: parsed?.reason });
541
+ ```
542
+
543
+ In-app purchase: verify → classify → key the row by billing cycle.
544
+
545
+ ```ts
546
+ import {
547
+ verifyAppleReceipt,
548
+ classifyAppleRenewal,
549
+ iapFailureKey,
550
+ serializeIapFailureReason,
551
+ } from '@rdlabo/workers-hono-kit';
552
+
553
+ const verify = await verifyAppleReceipt(receipt, { password: appleSharedSecret });
554
+ const cls = classifyAppleRenewal(verify, Date.now());
555
+ if (cls.state === 'billing_retry' || cls.state === 'lapsed') {
556
+ await db.write.insert(paymentFailed).values({
557
+ type: 'ios',
558
+ status: cls.state === 'billing_retry' ? 'failed' : 'canceled',
559
+ recursions_id: iapFailureKey({
560
+ platform: 'ios',
561
+ originalTransactionId: cls.originalTransactionId!,
562
+ expiresDateMs: cls.expiresDateMs!,
563
+ }),
564
+ receipt: serializeIapFailureReason({
565
+ code: cls.state === 'billing_retry' ? 'billing_retry' : 'subscription_canceled',
566
+ statusCode: cls.statusCode,
567
+ billingRetryStatus: cls.billingRetryStatus,
568
+ autoRenewStatus: cls.autoRenewStatus,
569
+ }),
570
+ });
571
+ }
572
+ ```
573
+
507
574
  ### Testing
508
575
 
509
576
  ```ts
@@ -1,11 +1,11 @@
1
1
  /**
2
- * HTTP status codes mirroring the `HttpStatus` enum from `@nestjs/common`.
2
+ * Standard HTTP status codes, keyed by their conventional names.
3
3
  *
4
4
  * @remarks
5
- * Provides a single source of truth for referencing status codes by the same names NestJS uses, so a
6
- * Hono app can emit responses whose status matches a NestJS service byte-for-byte. The member set and
7
- * numeric values intentionally track `@nestjs/common` rather than the IANA registry, including a few
8
- * non-standard codes that NestJS ships.
5
+ * A single source of truth for referencing status codes by name from a Hono app. The member set and
6
+ * numeric values follow the IANA HTTP status code registry. (Earlier versions mirrored the
7
+ * `@nestjs/common` `HttpStatus` enum including its non-standard codes and aliases — for a
8
+ * NestJS Hono migration; that parity is no longer maintained.)
9
9
  */
10
10
  export declare enum HttpStatus {
11
11
  CONTINUE = 100,
@@ -21,10 +21,7 @@ export declare enum HttpStatus {
21
21
  PARTIAL_CONTENT = 206,
22
22
  MULTI_STATUS = 207,
23
23
  ALREADY_REPORTED = 208,
24
- /** Non-standard WebDAV extension carried over from NestJS. */
25
- CONTENT_DIFFERENT = 210,
26
- /** Multiple Choices (300). Named `AMBIGUOUS` to match NestJS. */
27
- AMBIGUOUS = 300,
24
+ MULTIPLE_CHOICES = 300,
28
25
  MOVED_PERMANENTLY = 301,
29
26
  FOUND = 302,
30
27
  SEE_OTHER = 303,
@@ -51,15 +48,12 @@ export declare enum HttpStatus {
51
48
  EXPECTATION_FAILED = 417,
52
49
  /** "I'm a teapot" (418), from RFC 2324. */
53
50
  I_AM_A_TEAPOT = 418,
54
- /** Misdirected Request (421). Named `MISDIRECTED` to match NestJS. */
55
- MISDIRECTED = 421,
51
+ MISDIRECTED_REQUEST = 421,
56
52
  UNPROCESSABLE_ENTITY = 422,
57
53
  LOCKED = 423,
58
54
  FAILED_DEPENDENCY = 424,
59
55
  PRECONDITION_REQUIRED = 428,
60
56
  TOO_MANY_REQUESTS = 429,
61
- /** Non-standard code carried over from NestJS. */
62
- UNRECOVERABLE_ERROR = 456,
63
57
  INTERNAL_SERVER_ERROR = 500,
64
58
  NOT_IMPLEMENTED = 501,
65
59
  BAD_GATEWAY = 502,
@@ -1,11 +1,11 @@
1
1
  /**
2
- * HTTP status codes mirroring the `HttpStatus` enum from `@nestjs/common`.
2
+ * Standard HTTP status codes, keyed by their conventional names.
3
3
  *
4
4
  * @remarks
5
- * Provides a single source of truth for referencing status codes by the same names NestJS uses, so a
6
- * Hono app can emit responses whose status matches a NestJS service byte-for-byte. The member set and
7
- * numeric values intentionally track `@nestjs/common` rather than the IANA registry, including a few
8
- * non-standard codes that NestJS ships.
5
+ * A single source of truth for referencing status codes by name from a Hono app. The member set and
6
+ * numeric values follow the IANA HTTP status code registry. (Earlier versions mirrored the
7
+ * `@nestjs/common` `HttpStatus` enum including its non-standard codes and aliases — for a
8
+ * NestJS Hono migration; that parity is no longer maintained.)
9
9
  */
10
10
  export var HttpStatus;
11
11
  (function (HttpStatus) {
@@ -22,10 +22,7 @@ export var HttpStatus;
22
22
  HttpStatus[HttpStatus["PARTIAL_CONTENT"] = 206] = "PARTIAL_CONTENT";
23
23
  HttpStatus[HttpStatus["MULTI_STATUS"] = 207] = "MULTI_STATUS";
24
24
  HttpStatus[HttpStatus["ALREADY_REPORTED"] = 208] = "ALREADY_REPORTED";
25
- /** Non-standard WebDAV extension carried over from NestJS. */
26
- HttpStatus[HttpStatus["CONTENT_DIFFERENT"] = 210] = "CONTENT_DIFFERENT";
27
- /** Multiple Choices (300). Named `AMBIGUOUS` to match NestJS. */
28
- HttpStatus[HttpStatus["AMBIGUOUS"] = 300] = "AMBIGUOUS";
25
+ HttpStatus[HttpStatus["MULTIPLE_CHOICES"] = 300] = "MULTIPLE_CHOICES";
29
26
  HttpStatus[HttpStatus["MOVED_PERMANENTLY"] = 301] = "MOVED_PERMANENTLY";
30
27
  HttpStatus[HttpStatus["FOUND"] = 302] = "FOUND";
31
28
  HttpStatus[HttpStatus["SEE_OTHER"] = 303] = "SEE_OTHER";
@@ -52,15 +49,12 @@ export var HttpStatus;
52
49
  HttpStatus[HttpStatus["EXPECTATION_FAILED"] = 417] = "EXPECTATION_FAILED";
53
50
  /** "I'm a teapot" (418), from RFC 2324. */
54
51
  HttpStatus[HttpStatus["I_AM_A_TEAPOT"] = 418] = "I_AM_A_TEAPOT";
55
- /** Misdirected Request (421). Named `MISDIRECTED` to match NestJS. */
56
- HttpStatus[HttpStatus["MISDIRECTED"] = 421] = "MISDIRECTED";
52
+ HttpStatus[HttpStatus["MISDIRECTED_REQUEST"] = 421] = "MISDIRECTED_REQUEST";
57
53
  HttpStatus[HttpStatus["UNPROCESSABLE_ENTITY"] = 422] = "UNPROCESSABLE_ENTITY";
58
54
  HttpStatus[HttpStatus["LOCKED"] = 423] = "LOCKED";
59
55
  HttpStatus[HttpStatus["FAILED_DEPENDENCY"] = 424] = "FAILED_DEPENDENCY";
60
56
  HttpStatus[HttpStatus["PRECONDITION_REQUIRED"] = 428] = "PRECONDITION_REQUIRED";
61
57
  HttpStatus[HttpStatus["TOO_MANY_REQUESTS"] = 429] = "TOO_MANY_REQUESTS";
62
- /** Non-standard code carried over from NestJS. */
63
- HttpStatus[HttpStatus["UNRECOVERABLE_ERROR"] = 456] = "UNRECOVERABLE_ERROR";
64
58
  HttpStatus[HttpStatus["INTERNAL_SERVER_ERROR"] = 500] = "INTERNAL_SERVER_ERROR";
65
59
  HttpStatus[HttpStatus["NOT_IMPLEMENTED"] = 501] = "NOT_IMPLEMENTED";
66
60
  HttpStatus[HttpStatus["BAD_GATEWAY"] = 502] = "BAD_GATEWAY";
package/dist/index.d.ts CHANGED
@@ -47,7 +47,7 @@ export { KVCache } from './cache/kv-cache.js';
47
47
  export type { KVNamespace, KVCacheOptions } from './cache/kv-cache.js';
48
48
  export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
49
49
  export type { CreateStripeClientOptions } from './stripe/client.js';
50
- export { extractStripeFailureReason, stripeFailureMessageJa, serializePaymentFailure, parsePaymentFailure, PaymentDeclinedError, toPaymentDeclinedError, } from './stripe/failure.js';
50
+ export { extractStripeFailureReason, stripeFailureMessageJa, serializePaymentFailure, serializeIapFailureReason, parsePaymentFailure, PaymentDeclinedError, toPaymentDeclinedError, } from './stripe/failure.js';
51
51
  export type { StripeFailureReason, PaymentFailureSource, PaymentFailureRecord, PaymentFailureReason, IapFailureReason, PaymentDeclinedBody, } from './stripe/failure.js';
52
52
  export { classifyStripeReconcile } from './stripe/reconcile.js';
53
53
  export type { StripeReconcileAction } from './stripe/reconcile.js';
package/dist/index.js CHANGED
@@ -36,7 +36,7 @@ export { createSentryErrorReporter } from './http/http-error.js';
36
36
  export { KVCache } from './cache/kv-cache.js';
37
37
  // stripe
38
38
  export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
39
- export { extractStripeFailureReason, stripeFailureMessageJa, serializePaymentFailure, parsePaymentFailure, PaymentDeclinedError, toPaymentDeclinedError, } from './stripe/failure.js';
39
+ export { extractStripeFailureReason, stripeFailureMessageJa, serializePaymentFailure, serializeIapFailureReason, parsePaymentFailure, PaymentDeclinedError, toPaymentDeclinedError, } from './stripe/failure.js';
40
40
  export { classifyStripeReconcile } from './stripe/reconcile.js';
41
41
  // payment (provider-agnostic; web-standard only). reopenGuardedPaymentFailedSet is drizzle-based → './db'.
42
42
  export { paymentFailureMessageJa, iapFailureKey, UNRESOLVED_PAYMENT_STATUSES } from './payment/failure.js';
@@ -1,21 +1,21 @@
1
1
  import type { MiddlewareHandler } from 'hono';
2
2
  /**
3
- * Create a Hono middleware that finalizes responses for byte-parity with an Express/Nest backend.
3
+ * Create a Hono middleware that adds a weak ETag to buffered responses.
4
4
  *
5
- * After the downstream handler runs, it performs two adjustments:
5
+ * @remarks
6
+ * ETag generation (and conditional `If-None-Match` → `304 Not Modified` handling) is delegated to
7
+ * Hono's official `hono/etag` middleware in weak mode, so the ETag format is `W/"<sha1-hex>"`. This
8
+ * no longer emulates the Express `etag` package byte-for-byte — the kit dropped NestJS/Express parity.
6
9
  *
7
- * 1. **JSON charset**: Express's `res.json` emits `application/json; charset=utf-8`, whereas Hono's
8
- * `c.json` emits a bare `application/json`. A bare `application/json` content type is rewritten
9
- * to include `; charset=utf-8`.
10
- * 2. **Weak ETag**: An Express `etag`-package compatible weak ETag is added, matching the format an
11
- * Express/Nest backend applies to responses by default. See {@link weakEtag} for the exact format.
10
+ * Two categories of response are skipped so the downstream stream is never consumed:
12
11
  *
13
- * Server-Sent Events (`text/event-stream`) are skipped entirely because the stream cannot be
14
- * buffered. ETag generation is also skipped for `204`/`304` responses, responses that already carry
15
- * an `etag` header, and responses without a body.
12
+ * 1. **Server-Sent Events** (`text/event-stream`): the stream cannot be buffered to hash.
13
+ * 2. **Bodyless responses** (e.g. `204`/`304`, or handlers returning `null`): nothing to hash.
16
14
  *
17
- * @returns A {@link MiddlewareHandler} that rewrites the response headers (and body, when an ETag
18
- * must be computed) in place.
15
+ * Responses that already carry an `ETag` header are left untouched by `hono/etag`.
16
+ *
17
+ * @returns A {@link MiddlewareHandler} that sets the `ETag` header (or short-circuits to `304` when
18
+ * the request's `If-None-Match` matches).
19
19
  *
20
20
  * @example
21
21
  * ```ts
@@ -25,8 +25,7 @@ import type { MiddlewareHandler } from 'hono';
25
25
  * const app = new Hono();
26
26
  * app.use('*', finalizeResponse());
27
27
  * app.get('/users', (c) => c.json({ ok: true }));
28
- * // → Content-Type: application/json; charset=utf-8
29
- * // → ETag: W/"b-..." (b = 0xb = 11 bytes, the length of `{"ok":true}`)
28
+ * // → ETag: W/"<sha1-hex of the body>"
30
29
  * ```
31
30
  */
32
31
  export declare function finalizeResponse(): MiddlewareHandler;
@@ -1,41 +1,29 @@
1
+ import { etag } from 'hono/etag';
1
2
  /**
2
- * Compute an Express `etag`-package compatible weak ETag for a response body.
3
+ * Pre-built Hono ETag middleware in weak mode.
3
4
  *
4
- * The format is `W/"<byteLength-in-hex>-<first 27 chars of base64(sha1(body))>"`, byte-for-byte
5
- * identical to the weak ETag produced by the Express `etag` package. This deliberately differs
6
- * from `hono/etag`'s own format so responses match an Express/Nest backend exactly.
7
- *
8
- * @param body - The raw response body bytes to hash.
9
- * @returns The weak ETag header value (e.g. `W/"1a-Qwerty..."`).
5
+ * @remarks
6
+ * Reused across requests; it is stateless per invocation (all state lives on the passed `Context`).
10
7
  * @internal
11
8
  */
12
- async function weakEtag(body) {
13
- const digest = await crypto.subtle.digest('SHA-1', body);
14
- const bytes = new Uint8Array(digest);
15
- let bin = '';
16
- for (const b of bytes) {
17
- bin += String.fromCharCode(b);
18
- }
19
- const b64 = btoa(bin).substring(0, 27);
20
- return `W/"${body.byteLength.toString(16)}-${b64}"`;
21
- }
9
+ const applyWeakEtag = etag({ weak: true });
22
10
  /**
23
- * Create a Hono middleware that finalizes responses for byte-parity with an Express/Nest backend.
11
+ * Create a Hono middleware that adds a weak ETag to buffered responses.
24
12
  *
25
- * After the downstream handler runs, it performs two adjustments:
13
+ * @remarks
14
+ * ETag generation (and conditional `If-None-Match` → `304 Not Modified` handling) is delegated to
15
+ * Hono's official `hono/etag` middleware in weak mode, so the ETag format is `W/"<sha1-hex>"`. This
16
+ * no longer emulates the Express `etag` package byte-for-byte — the kit dropped NestJS/Express parity.
26
17
  *
27
- * 1. **JSON charset**: Express's `res.json` emits `application/json; charset=utf-8`, whereas Hono's
28
- * `c.json` emits a bare `application/json`. A bare `application/json` content type is rewritten
29
- * to include `; charset=utf-8`.
30
- * 2. **Weak ETag**: An Express `etag`-package compatible weak ETag is added, matching the format an
31
- * Express/Nest backend applies to responses by default. See {@link weakEtag} for the exact format.
18
+ * Two categories of response are skipped so the downstream stream is never consumed:
32
19
  *
33
- * Server-Sent Events (`text/event-stream`) are skipped entirely because the stream cannot be
34
- * buffered. ETag generation is also skipped for `204`/`304` responses, responses that already carry
35
- * an `etag` header, and responses without a body.
20
+ * 1. **Server-Sent Events** (`text/event-stream`): the stream cannot be buffered to hash.
21
+ * 2. **Bodyless responses** (e.g. `204`/`304`, or handlers returning `null`): nothing to hash.
36
22
  *
37
- * @returns A {@link MiddlewareHandler} that rewrites the response headers (and body, when an ETag
38
- * must be computed) in place.
23
+ * Responses that already carry an `ETag` header are left untouched by `hono/etag`.
24
+ *
25
+ * @returns A {@link MiddlewareHandler} that sets the `ETag` header (or short-circuits to `304` when
26
+ * the request's `If-None-Match` matches).
39
27
  *
40
28
  * @example
41
29
  * ```ts
@@ -45,38 +33,19 @@ async function weakEtag(body) {
45
33
  * const app = new Hono();
46
34
  * app.use('*', finalizeResponse());
47
35
  * app.get('/users', (c) => c.json({ ok: true }));
48
- * // → Content-Type: application/json; charset=utf-8
49
- * // → ETag: W/"b-..." (b = 0xb = 11 bytes, the length of `{"ok":true}`)
36
+ * // → ETag: W/"<sha1-hex of the body>"
50
37
  * ```
51
38
  */
52
39
  export function finalizeResponse() {
53
40
  return async (c, next) => {
54
41
  await next();
55
- const status = c.res.status;
56
42
  const contentType = c.res.headers.get('content-type') ?? '';
57
- // Leave SSE / streaming responses untouched.
58
- if (contentType.includes('text/event-stream')) {
59
- return;
60
- }
61
- // Charset target: add the charset when a JSON response leaves it unspecified.
62
- const needsCharset = contentType === 'application/json';
63
- // ETag target: Express omits it on 204/304 and respects an existing ETag.
64
- const needsEtag = status !== 204 && status !== 304 && !c.res.headers.has('etag') && !!c.res.body;
65
- if (!needsCharset && !needsEtag) {
43
+ // Leave SSE / streaming responses untouched, and skip bodyless responses (204/304/null).
44
+ if (contentType.includes('text/event-stream') || !c.res.body) {
66
45
  return;
67
46
  }
68
- const headers = new Headers(c.res.headers);
69
- if (needsCharset) {
70
- headers.set('content-type', 'application/json; charset=utf-8');
71
- }
72
- if (needsEtag) {
73
- const buf = await c.res.clone().arrayBuffer();
74
- headers.set('ETag', await weakEtag(buf));
75
- c.res = new Response(buf, { status, statusText: c.res.statusText, headers });
76
- }
77
- else {
78
- // Swap only the headers without reading the body.
79
- c.res = new Response(c.res.body, { status, statusText: c.res.statusText, headers });
80
- }
47
+ // The downstream handler already ran, so pass a no-op `next`; `hono/etag` then hashes the
48
+ // buffered body and applies the ETag (or a conditional 304) to the existing response.
49
+ await applyWeakEtag(c, async () => undefined);
81
50
  };
82
51
  }
@@ -32,7 +32,7 @@ export function paymentFailureMessageJa(input) {
32
32
  if (input.status === 'failed' && (input.type === 'ios' || input.type === 'android')) {
33
33
  return IAP_FAILED_MESSAGE_JA[input.type];
34
34
  }
35
- const stripeReason = input.reason && !('provider' in input.reason) ? input.reason : null;
35
+ const stripeReason = input.type === 'ios' || input.type === 'android' ? null : (input.reason ?? null);
36
36
  return stripeFailureMessageJa(stripeReason);
37
37
  }
38
38
  /**
@@ -24,8 +24,6 @@ export interface StripeFailureReason {
24
24
  }
25
25
  /** Normalized reason persisted for an App Store / Google Play subscription failure. */
26
26
  export interface IapFailureReason {
27
- /** Provider discriminator. */
28
- provider: 'ios' | 'android';
29
27
  /** Stable machine-readable classification. */
30
28
  code: 'billing_retry' | 'auto_renew_off' | 'subscription_canceled' | 'subscription_gone';
31
29
  /** Provider response status code when present (Apple verifyReceipt status / Google error code). */
@@ -50,9 +48,10 @@ export type PaymentFailureSource = 'webhook.invoice.payment_failed' | 'webhook.i
50
48
  */
51
49
  export interface PaymentFailureRecord {
52
50
  reason: PaymentFailureReason;
53
- source: PaymentFailureSource;
51
+ /** Capture path. Optional for IAP because `payment_failed.type` already identifies the provider/path. */
52
+ source?: PaymentFailureSource;
54
53
  /** ISO 8601 timestamp of when the failure was captured. */
55
- occurredAt: string;
54
+ occurredAt?: string;
56
55
  }
57
56
  /**
58
57
  * Extract a normalized {@link StripeFailureReason} from a Stripe `PaymentIntent`, `Invoice`, a
@@ -86,6 +85,8 @@ export declare function stripeFailureMessageJa(reason: StripeFailureReason | nul
86
85
  * @returns A JSON string.
87
86
  */
88
87
  export declare function serializePaymentFailure(record: PaymentFailureRecord): string;
88
+ /** Serialize an IAP reason directly, without redundant provider/source/timestamp wrappers. */
89
+ export declare function serializeIapFailureReason(reason: IapFailureReason): string;
89
90
  /**
90
91
  * Parse a `payment_failed.receipt` value back into a {@link PaymentFailureRecord}.
91
92
  *
@@ -148,6 +148,10 @@ export function stripeFailureMessageJa(reason) {
148
148
  export function serializePaymentFailure(record) {
149
149
  return JSON.stringify(record);
150
150
  }
151
+ /** Serialize an IAP reason directly, without redundant provider/source/timestamp wrappers. */
152
+ export function serializeIapFailureReason(reason) {
153
+ return JSON.stringify(reason);
154
+ }
151
155
  /**
152
156
  * Parse a `payment_failed.receipt` value back into a {@link PaymentFailureRecord}.
153
157
  *
@@ -161,10 +165,21 @@ export function parsePaymentFailure(receipt) {
161
165
  try {
162
166
  const parsed = JSON.parse(receipt);
163
167
  const r = asRecord(parsed);
164
- if (!r || !asRecord(r.reason) || typeof r.source !== 'string' || typeof r.occurredAt !== 'string') {
168
+ if (!r) {
165
169
  return null;
166
170
  }
167
- return parsed;
171
+ if (asRecord(r.reason)) {
172
+ if ((r.source !== undefined && typeof r.source !== 'string') ||
173
+ (r.occurredAt !== undefined && typeof r.occurredAt !== 'string')) {
174
+ return null;
175
+ }
176
+ return parsed;
177
+ }
178
+ // IAP rows store the reason itself. `code` is required so arbitrary JSON is not accepted as a reason.
179
+ if (typeof r.code === 'string') {
180
+ return { reason: parsed };
181
+ }
182
+ return null;
168
183
  }
169
184
  catch {
170
185
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.6.10",
3
+ "version": "0.6.13",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,149 +0,0 @@
1
- import type { Context, Env } from 'hono';
2
- import type { ContentfulStatusCode } from 'hono/utils/http-status';
3
- /**
4
- * Reason phrases attached by the NestJS default exception filter, keyed by HTTP status code.
5
- *
6
- * @remarks
7
- * Mirrors the `error` field values NestJS produces for common client-error statuses, so a Hono app can
8
- * return byte-identical error bodies. Used as the default `reasonPhrases` map by {@link createNestErrorHandler}.
9
- */
10
- export declare const NEST_REASON_PHRASES: Record<number, string>;
11
- /**
12
- * Contextual metadata passed to an {@link ErrorReporter} when reporting an unexpected error.
13
- */
14
- export interface ErrorReportContext {
15
- /** Correlation id for the failing request, if one is tracked. */
16
- requestId?: string;
17
- }
18
- /**
19
- * Signature of a function that reports an unexpected (non-HTTP) error to an external sink such as Sentry.
20
- *
21
- * @remarks
22
- * Wire it into {@link createNestErrorHandler} via `onUnhandledError`, e.g.
23
- * `(err, c) => reporter(err, { requestId: c.get('requestId') })`. The reporting client itself is
24
- * intentionally kept out of this kit; the consumer supplies the implementation.
25
- *
26
- * @param error - The thrown value being reported.
27
- * @param context - Optional correlation context for the failing request.
28
- */
29
- export type ErrorReporter = (error: unknown, context?: ErrorReportContext) => void;
30
- /**
31
- * Minimal Sentry-like client for {@link createSentryErrorReporter} and {@link createQueueErrorHandler}.
32
- *
33
- * @remarks
34
- * Declared structurally to avoid a hard dependency on `@sentry/cloudflare`.
35
- */
36
- export interface SentryExceptionReporterLike {
37
- captureException(exception: unknown, captureContext?: {
38
- tags?: Record<string, string>;
39
- extra?: Record<string, unknown>;
40
- }): void;
41
- }
42
- /**
43
- * Build an {@link ErrorReporter} that forwards unhandled errors to Sentry with an optional `request_id` tag.
44
- */
45
- export declare function createSentryErrorReporter(sentry: SentryExceptionReporterLike): ErrorReporter;
46
- /**
47
- * Minimal shape read from a value treated as an HTTP error: its status, message, and optional body.
48
- *
49
- * @internal
50
- */
51
- interface HttpErrorLike {
52
- /** HTTP status code to respond with. */
53
- status: ContentfulStatusCode;
54
- /** Human-readable error message placed in the response body. */
55
- message: string;
56
- /**
57
- * Escape hatch for a fully custom response body. When present, it is rendered verbatim instead of
58
- * the NestJS-shaped body.
59
- */
60
- body?: unknown;
61
- }
62
- /**
63
- * Options controlling how {@link createNestErrorHandler} shapes error responses.
64
- *
65
- * @typeParam E - The Hono environment type, so `onUnhandledError` receives a correctly typed context.
66
- */
67
- export interface NestErrorHandlerOptions<E extends Env = Env> {
68
- /** Status-to-reason-phrase map for the `error` field. Defaults to {@link NEST_REASON_PHRASES}. */
69
- reasonPhrases?: Record<number, string>;
70
- /**
71
- * Statuses that return only `{ statusCode, message }`, omitting the `error` field. Defaults to `[401]`,
72
- * matching NestJS where a generic `HttpException(msg, 401)` carries no `error`.
73
- */
74
- bareStatuses?: readonly number[];
75
- /**
76
- * Field order of the non-bare error body. Defaults to `'statusCode-first'` (the NestJS canonical order).
77
- * Use `'message-first'` to emit `{ message, error, statusCode }` when byte parity requires it.
78
- */
79
- fieldOrder?: 'statusCode-first' | 'message-first';
80
- /**
81
- * Fallback `error` value for statuses that are neither bare nor present in `reasonPhrases`. Defaults to
82
- * `undefined`, meaning the `error` field is omitted when no reason phrase is known. Set to a string such
83
- * as `'Error'` to always include an `error` field, faithfully reproducing the NestJS default exception
84
- * filter behavior where `error` is always present.
85
- */
86
- fallbackReason?: string;
87
- /**
88
- * Predicate identifying which thrown values are HTTP errors. Defaults to detecting Hono's `HTTPException`.
89
- * Override it (e.g. `(e) => e instanceof MyHttpError`) when the app throws a custom HTTP error type.
90
- */
91
- isHttpError?: (err: unknown) => err is HttpErrorLike;
92
- /**
93
- * Hook invoked before an unexpected (non-HTTP) error is returned as a 500, typically used to report the
94
- * error (e.g. to Sentry). Any exception thrown by this hook is swallowed so reporting cannot alter the
95
- * error response.
96
- */
97
- onUnhandledError?: (err: unknown, c: Context<E>) => void;
98
- /**
99
- * Response body for unexpected errors returned as 500. Defaults to
100
- * `{ statusCode: 500, message: 'Internal server error' }`.
101
- */
102
- internalServerErrorBody?: unknown;
103
- }
104
- /**
105
- * Create a Hono `onError` handler that maps thrown errors to NestJS-shaped error JSON.
106
- *
107
- * @remarks
108
- * Reproduces the NestJS default exception filter so a Hono app returns byte-identical error bodies:
109
- * - HTTP errors (by default `HTTPException`) are mapped to a NestJS-shaped body; if the error carries a
110
- * custom `body`, that body is returned verbatim.
111
- * - Statuses listed in `bareStatuses` (default `[401]`) omit the `error` field.
112
- * - Any other (unexpected) error triggers `onUnhandledError`, is logged via `console.error`, and returns 500.
113
- *
114
- * Per-app differences in body field order, HTTP error type, and reporting hook are absorbed through
115
- * {@link NestErrorHandlerOptions}, while the branching logic stays shared.
116
- *
117
- * @typeParam E - The Hono environment type propagated to `onUnhandledError`.
118
- * @param options - Overrides for reason phrases, bare statuses, field order, error detection, and reporting.
119
- * @returns A handler suitable for `app.onError(...)`.
120
- *
121
- * @example
122
- * ```ts
123
- * app.onError(
124
- * createNestErrorHandler({
125
- * fieldOrder: 'message-first',
126
- * fallbackReason: 'Error',
127
- * onUnhandledError: (err, c) => reportError(err, { requestId: c.get('requestId') }),
128
- * }),
129
- * );
130
- * ```
131
- */
132
- export declare function createNestErrorHandler<E extends Env = Env>(options?: NestErrorHandlerOptions<E>): (err: Error, c: Context<E>) => Response;
133
- /**
134
- * Hono `notFound` handler that returns the canonical Express/NestJS unmatched-route 404 body.
135
- *
136
- * @remarks
137
- * Produces `{ message: "Cannot <METHOD> <path>", error: 'Not Found', statusCode: 404 }`, matching the
138
- * NestJS default 404 response so unmatched routes stay byte-identical.
139
- *
140
- * @param c - The Hono request context for the unmatched route.
141
- * @returns A 404 JSON response.
142
- *
143
- * @example
144
- * ```ts
145
- * app.notFound(nestNotFoundHandler);
146
- * ```
147
- */
148
- export declare function nestNotFoundHandler(c: Context): Response;
149
- export {};
@@ -1,120 +0,0 @@
1
- import { findMysqlDriverError, logMysqlDriverError } from './mysql-driver-error.js';
2
- /**
3
- * Reason phrases attached by the NestJS default exception filter, keyed by HTTP status code.
4
- *
5
- * @remarks
6
- * Mirrors the `error` field values NestJS produces for common client-error statuses, so a Hono app can
7
- * return byte-identical error bodies. Used as the default `reasonPhrases` map by {@link createNestErrorHandler}.
8
- */
9
- export const NEST_REASON_PHRASES = {
10
- 400: 'Bad Request',
11
- 401: 'Unauthorized',
12
- 403: 'Forbidden',
13
- 404: 'Not Found',
14
- };
15
- /**
16
- * Build an {@link ErrorReporter} that forwards unhandled errors to Sentry with an optional `request_id` tag.
17
- */
18
- export function createSentryErrorReporter(sentry) {
19
- return (error, context) => {
20
- sentry.captureException(error, context?.requestId ? { tags: { request_id: context.requestId } } : undefined);
21
- };
22
- }
23
- /**
24
- * Structurally detect Hono's `HTTPException` without relying on `instanceof`.
25
- *
26
- * @remarks
27
- * When this kit is symlinked into a consumer, the `hono` instance it resolves can differ from the
28
- * consumer's `hono`, so an `HTTPException` from one copy fails an `instanceof` check against the other.
29
- * Detecting the presence of a `getResponse()` method and a numeric `status` is stable across module
30
- * boundaries and production bundles.
31
- *
32
- * @param err - The thrown value to test.
33
- * @returns `true` when `err` looks like a Hono `HTTPException`.
34
- *
35
- * @internal
36
- */
37
- const isHTTPException = (err) => err instanceof Error &&
38
- typeof err.getResponse === 'function' &&
39
- typeof err.status === 'number';
40
- /**
41
- * Create a Hono `onError` handler that maps thrown errors to NestJS-shaped error JSON.
42
- *
43
- * @remarks
44
- * Reproduces the NestJS default exception filter so a Hono app returns byte-identical error bodies:
45
- * - HTTP errors (by default `HTTPException`) are mapped to a NestJS-shaped body; if the error carries a
46
- * custom `body`, that body is returned verbatim.
47
- * - Statuses listed in `bareStatuses` (default `[401]`) omit the `error` field.
48
- * - Any other (unexpected) error triggers `onUnhandledError`, is logged via `console.error`, and returns 500.
49
- *
50
- * Per-app differences in body field order, HTTP error type, and reporting hook are absorbed through
51
- * {@link NestErrorHandlerOptions}, while the branching logic stays shared.
52
- *
53
- * @typeParam E - The Hono environment type propagated to `onUnhandledError`.
54
- * @param options - Overrides for reason phrases, bare statuses, field order, error detection, and reporting.
55
- * @returns A handler suitable for `app.onError(...)`.
56
- *
57
- * @example
58
- * ```ts
59
- * app.onError(
60
- * createNestErrorHandler({
61
- * fieldOrder: 'message-first',
62
- * fallbackReason: 'Error',
63
- * onUnhandledError: (err, c) => reportError(err, { requestId: c.get('requestId') }),
64
- * }),
65
- * );
66
- * ```
67
- */
68
- export function createNestErrorHandler(options = {}) {
69
- const { reasonPhrases = NEST_REASON_PHRASES, bareStatuses = [401], fieldOrder = 'statusCode-first', isHttpError = isHTTPException, onUnhandledError, internalServerErrorBody = { statusCode: 500, message: 'Internal server error' }, fallbackReason, } = options;
70
- return (err, c) => {
71
- if (isHttpError(err)) {
72
- // Escape hatch for a custom error body: render it verbatim.
73
- if (err.body !== undefined) {
74
- return c.json(err.body, err.status);
75
- }
76
- // reasonPhrases[status] is typed as string, but with noUncheckedIndexedAccess disabled it can be
77
- // undefined at runtime. The fallbackReason fallback for unregistered statuses is intentional.
78
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
79
- const reason = bareStatuses.includes(err.status) ? undefined : (reasonPhrases[err.status] ?? fallbackReason);
80
- if (reason === undefined) {
81
- return c.json({ statusCode: err.status, message: err.message }, err.status);
82
- }
83
- const body = fieldOrder === 'message-first'
84
- ? { message: err.message, error: reason, statusCode: err.status }
85
- : { statusCode: err.status, message: err.message, error: reason };
86
- return c.json(body, err.status);
87
- }
88
- try {
89
- onUnhandledError?.(err, c);
90
- }
91
- catch {
92
- // Reporting must never change the behavior of the error response.
93
- }
94
- if (findMysqlDriverError(err)) {
95
- logMysqlDriverError(err, 500);
96
- }
97
- else {
98
- console.error(err);
99
- }
100
- return c.json(internalServerErrorBody, 500);
101
- };
102
- }
103
- /**
104
- * Hono `notFound` handler that returns the canonical Express/NestJS unmatched-route 404 body.
105
- *
106
- * @remarks
107
- * Produces `{ message: "Cannot <METHOD> <path>", error: 'Not Found', statusCode: 404 }`, matching the
108
- * NestJS default 404 response so unmatched routes stay byte-identical.
109
- *
110
- * @param c - The Hono request context for the unmatched route.
111
- * @returns A 404 JSON response.
112
- *
113
- * @example
114
- * ```ts
115
- * app.notFound(nestNotFoundHandler);
116
- * ```
117
- */
118
- export function nestNotFoundHandler(c) {
119
- return c.json({ message: `Cannot ${c.req.method} ${new URL(c.req.url).pathname}`, error: 'Not Found', statusCode: 404 }, 404);
120
- }
@@ -1,24 +0,0 @@
1
- import type { SQL } from 'drizzle-orm';
2
- /**
3
- * `payment_failed` persistence helpers shared across the fleet.
4
- *
5
- * @remarks
6
- * `drizzle-orm` is a **peer** of this kit (the consumer resolves a single copy). These helpers only
7
- * build SQL fragments — the consumer's repository executes them, so it works regardless of the
8
- * consumer's DB access layer (`db.write` / `helper.query`).
9
- */
10
- /**
11
- * The `onDuplicateKeyUpdate.set` for an `insert(paymentFailed)…` that **never re-opens a `resolved`
12
- * row** — the idempotency guard against Stripe's delayed/out-of-order webhook redelivery leaving a
13
- * paid user with a permanent failure banner.
14
- *
15
- * @remarks
16
- * `receipt` is evaluated **before** `status` (SET is left→right and later columns see already-updated
17
- * ones), so both branch on the *pre-update* `payment_failed.status`. `type`/`user_id` are always
18
- * refreshed. Spread into the call:
19
- * `insert(paymentFailed).values(...).onDuplicateKeyUpdate({ set: reopenGuardedPaymentFailedSet() })`.
20
- *
21
- * Assumes the canonical column names (`type`, `user_id`, `status`, `receipt`) and the drizzle schema
22
- * property names (`type`, `userId`, `status`, `receipt`) used fleet-wide.
23
- */
24
- export declare function reopenGuardedPaymentFailedSet(): Record<'type' | 'userId' | 'status' | 'receipt', SQL>;
@@ -1,32 +0,0 @@
1
- import { sql } from 'drizzle-orm';
2
- /**
3
- * `payment_failed` persistence helpers shared across the fleet.
4
- *
5
- * @remarks
6
- * `drizzle-orm` is a **peer** of this kit (the consumer resolves a single copy). These helpers only
7
- * build SQL fragments — the consumer's repository executes them, so it works regardless of the
8
- * consumer's DB access layer (`db.write` / `helper.query`).
9
- */
10
- /**
11
- * The `onDuplicateKeyUpdate.set` for an `insert(paymentFailed)…` that **never re-opens a `resolved`
12
- * row** — the idempotency guard against Stripe's delayed/out-of-order webhook redelivery leaving a
13
- * paid user with a permanent failure banner.
14
- *
15
- * @remarks
16
- * `receipt` is evaluated **before** `status` (SET is left→right and later columns see already-updated
17
- * ones), so both branch on the *pre-update* `payment_failed.status`. `type`/`user_id` are always
18
- * refreshed. Spread into the call:
19
- * `insert(paymentFailed).values(...).onDuplicateKeyUpdate({ set: reopenGuardedPaymentFailedSet() })`.
20
- *
21
- * Assumes the canonical column names (`type`, `user_id`, `status`, `receipt`) and the drizzle schema
22
- * property names (`type`, `userId`, `status`, `receipt`) used fleet-wide.
23
- */
24
- export function reopenGuardedPaymentFailedSet() {
25
- return {
26
- type: sql `values(\`type\`)`,
27
- userId: sql `values(\`user_id\`)`,
28
- // resolved 行は理由も status も据え置き(再オープンしない)。status 代入より前に評価する。
29
- receipt: sql `IF(\`payment_failed\`.\`status\` = 'resolved', \`payment_failed\`.\`receipt\`, values(\`receipt\`))`,
30
- status: sql `IF(\`payment_failed\`.\`status\` = 'resolved', \`payment_failed\`.\`status\`, values(\`status\`))`,
31
- };
32
- }