@rdlabo/workers-hono-kit 0.1.0 → 0.2.1
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 +126 -11
- package/dist/ai/gateway.d.ts +54 -16
- package/dist/ai/gateway.js +37 -12
- package/dist/aws/cloudfront.d.ts +23 -5
- package/dist/aws/cloudfront.js +45 -6
- package/dist/aws/secrets-manager.d.ts +38 -4
- package/dist/aws/secrets-manager.js +48 -3
- package/dist/cache/kv-cache.d.ts +173 -10
- package/dist/cache/kv-cache.js +139 -7
- package/dist/db/connection.d.ts +56 -14
- package/dist/db/connection.js +39 -13
- package/dist/db/database.d.ts +160 -24
- package/dist/db/database.js +51 -7
- package/dist/db/index.d.ts +21 -10
- package/dist/db/index.js +17 -8
- package/dist/db/jst.d.ts +89 -6
- package/dist/db/jst.js +89 -23
- package/dist/db/orm-config.d.ts +61 -19
- package/dist/db/orm-config.js +43 -14
- package/dist/db/retry.d.ts +25 -3
- package/dist/db/retry.js +25 -3
- package/dist/db/write-result.d.ts +27 -4
- package/dist/db/write-result.js +22 -1
- package/dist/firebase/firebase-verifier.d.ts +53 -4
- package/dist/firebase/identity-toolkit.d.ts +54 -5
- package/dist/firebase/identity-toolkit.js +51 -0
- package/dist/firebase/jose-firebase-verifier.d.ts +81 -9
- package/dist/firebase/jose-firebase-verifier.js +68 -7
- package/dist/firebase/remote-verifier.d.ts +43 -5
- package/dist/firebase/remote-verifier.js +60 -11
- package/dist/http/app-env.d.ts +41 -8
- package/dist/http/app-env.js +38 -8
- package/dist/http/app-info.d.ts +25 -3
- package/dist/http/app-info.js +16 -2
- package/dist/http/http-status.d.ts +12 -3
- package/dist/http/http-status.js +12 -3
- package/dist/http/nest-error.d.ts +90 -29
- package/dist/http/nest-error.js +59 -18
- package/dist/http/user-protocol.d.ts +23 -3
- package/dist/http/user-protocol.js +14 -2
- package/dist/index.d.ts +41 -30
- package/dist/index.js +29 -21
- package/dist/middleware/auth.d.ts +75 -14
- package/dist/middleware/auth.js +31 -7
- package/dist/middleware/finalize-response.d.ts +30 -0
- package/dist/middleware/finalize-response.js +41 -12
- package/dist/middleware/validation.d.ts +83 -9
- package/dist/middleware/validation.js +52 -9
- package/dist/middleware/zod-coerce.d.ts +56 -2
- package/dist/middleware/zod-coerce.js +68 -9
- package/dist/stripe/client.d.ts +46 -9
- package/dist/stripe/client.js +41 -3
- package/dist/testing/auth.d.ts +60 -12
- package/dist/testing/auth.js +58 -10
- package/dist/testing/configurable-fake.d.ts +20 -9
- package/dist/testing/configurable-fake.js +23 -11
- package/dist/testing/db.d.ts +81 -12
- package/dist/testing/db.js +23 -1
- package/dist/testing/fakes.d.ts +79 -11
- package/dist/testing/fakes.js +70 -8
- package/dist/testing/index.d.ts +15 -8
- package/dist/testing/index.js +15 -10
- package/dist/testing/stripe-fixtures.d.ts +93 -3
- package/dist/testing/stripe-fixtures.js +93 -3
- package/package.json +19 -9
- package/src/ai/gateway.ts +66 -27
- package/src/aws/cloudfront.ts +46 -6
- package/src/aws/secrets-manager.ts +56 -7
- package/src/cache/kv-cache.ts +194 -12
- package/src/db/connection.ts +56 -14
- package/src/db/database.ts +163 -27
- package/src/db/index.ts +21 -12
- package/src/db/jst.ts +89 -23
- package/src/db/orm-config.ts +61 -19
- package/src/db/retry.ts +25 -3
- package/src/db/write-result.ts +27 -4
- package/src/firebase/firebase-verifier.ts +53 -4
- package/src/firebase/identity-toolkit.ts +57 -5
- package/src/firebase/jose-firebase-verifier.ts +81 -11
- package/src/firebase/remote-verifier.ts +61 -12
- package/src/http/app-env.ts +41 -8
- package/src/http/app-info.ts +25 -3
- package/src/http/http-status.ts +12 -3
- package/src/http/nest-error.ts +106 -37
- package/src/http/user-protocol.ts +23 -3
- package/src/index.ts +47 -33
- package/src/middleware/auth.ts +79 -17
- package/src/middleware/finalize-response.ts +41 -12
- package/src/middleware/validation.ts +89 -15
- package/src/middleware/zod-coerce.ts +68 -9
- package/src/stripe/client.ts +46 -9
- package/src/testing/auth.ts +60 -12
- package/src/testing/configurable-fake.ts +23 -11
- package/src/testing/db.ts +82 -13
- package/src/testing/fakes.ts +80 -12
- package/src/testing/index.ts +18 -13
- package/src/testing/stripe-fixtures.ts +93 -3
package/src/aws/cloudfront.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CloudFront
|
|
3
|
-
* Cloudflare Workers ネイティブ(aws-sdk 不要)。フリート共通 = tipsys/winecode hono。
|
|
2
|
+
* Generate a CloudFront signed URL using a canned policy, implemented natively for Cloudflare Workers.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Reimplements `getSignedUrl` from `@aws-sdk/cloudfront-signer` on top of the Web Crypto API, so no
|
|
5
|
+
* `@aws-sdk` dependency is required. The canned policy is signed with RSASSA-PKCS1-v1_5 and SHA-1, the
|
|
6
|
+
* signature is converted to AWS URL-safe base64 (`+` -> `-`, `/` -> `~`, `=` -> `_`), and the query
|
|
7
|
+
* parameters are appended in the order `Expires`, `Key-Pair-Id`, `Signature`.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* The output is byte-for-byte identical to that of `@aws-sdk/cloudfront-signer`.
|
|
11
|
+
*
|
|
12
|
+
* @param url - The resource URL to sign.
|
|
13
|
+
* @param privateKeyPem - The CloudFront key group private key in PKCS#8 PEM format.
|
|
14
|
+
* @param keyPairId - The CloudFront public key (key pair) ID associated with the private key.
|
|
15
|
+
* @param dateLessThan - Expiry time, accepted as a `Date`, epoch-millisecond number, or date string.
|
|
16
|
+
* @returns The signed URL with the `Expires`, `Key-Pair-Id`, and `Signature` query parameters appended.
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const signedUrl = await getCloudFrontSignedUrl(
|
|
20
|
+
* 'https://cdn.example.com/private/video.mp4',
|
|
21
|
+
* env.CLOUDFRONT_PRIVATE_KEY,
|
|
22
|
+
* env.CLOUDFRONT_KEY_PAIR_ID,
|
|
23
|
+
* Date.now() + 60 * 60 * 1000, // valid for one hour
|
|
24
|
+
* );
|
|
25
|
+
* ```
|
|
8
26
|
*/
|
|
9
27
|
export async function getCloudFrontSignedUrl(
|
|
10
28
|
url: string,
|
|
@@ -34,14 +52,29 @@ export async function getCloudFrontSignedUrl(
|
|
|
34
52
|
const signature = toUrlSafeBase64(arrayBufferToBase64(signatureBuffer));
|
|
35
53
|
const separator = url.includes('?') ? '&' : '?';
|
|
36
54
|
|
|
37
|
-
// @aws-sdk/cloudfront-signer
|
|
55
|
+
// Query order used by @aws-sdk/cloudfront-signer: Expires -> Key-Pair-Id -> Signature
|
|
38
56
|
return `${url}${separator}Expires=${epochSeconds}&Key-Pair-Id=${keyPairId}&Signature=${signature}`;
|
|
39
57
|
}
|
|
40
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Convert standard base64 to the URL-safe alphabet expected in CloudFront signatures.
|
|
61
|
+
*
|
|
62
|
+
* @param value - A standard base64 string.
|
|
63
|
+
* @returns The base64 string with `+` -> `-`, `=` -> `_`, and `/` -> `~`.
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
|
|
41
67
|
function toUrlSafeBase64(value: string): string {
|
|
42
68
|
return value.replace(/\+/g, '-').replace(/=/g, '_').replace(/\//g, '~');
|
|
43
69
|
}
|
|
44
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Encode an `ArrayBuffer` to standard base64.
|
|
73
|
+
*
|
|
74
|
+
* @param buffer - The raw bytes to encode.
|
|
75
|
+
* @returns The standard base64 representation of the buffer.
|
|
76
|
+
* @internal
|
|
77
|
+
*/
|
|
45
78
|
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
46
79
|
const bytes = new Uint8Array(buffer);
|
|
47
80
|
let binary = '';
|
|
@@ -51,6 +84,13 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
|
51
84
|
return btoa(binary);
|
|
52
85
|
}
|
|
53
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Decode a PKCS#8 PEM private key into its DER `ArrayBuffer`.
|
|
89
|
+
*
|
|
90
|
+
* @param pem - The PEM-encoded key, including the BEGIN/END armor.
|
|
91
|
+
* @returns The decoded DER bytes, suitable for `crypto.subtle.importKey('pkcs8', ...)`.
|
|
92
|
+
* @internal
|
|
93
|
+
*/
|
|
54
94
|
function pemToDer(pem: string): ArrayBuffer {
|
|
55
95
|
const base64 = pem
|
|
56
96
|
.replace(/-----BEGIN [^-]+-----/, '')
|
|
@@ -1,26 +1,66 @@
|
|
|
1
1
|
import { AwsClient } from 'aws4fetch';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* AWS
|
|
5
|
-
* Cloudflare Workers には AWS SDK も IAM ロールも無いため、AWS の静的キーを Workers secrets として
|
|
6
|
-
* 渡して署名する(移植元 `api/src/secrets-manager.ts` 相当)。DB 認証情報は Hyperdrive 側に持つので対象外。
|
|
4
|
+
* AWS credentials used to sign Secrets Manager requests.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* Cloudflare Workers have neither the AWS SDK nor IAM role credentials, so static AWS keys are supplied
|
|
8
|
+
* as Workers secrets and used to produce a SigV4 signature.
|
|
9
9
|
*/
|
|
10
10
|
export interface AwsSecretsOptions {
|
|
11
|
+
/** AWS access key ID. */
|
|
11
12
|
accessKeyId: string;
|
|
13
|
+
/** AWS secret access key. */
|
|
12
14
|
secretAccessKey: string;
|
|
15
|
+
/** Optional STS session token, required when using temporary credentials. */
|
|
13
16
|
sessionToken?: string;
|
|
17
|
+
/** AWS region of the Secrets Manager endpoint, e.g. `ap-northeast-1`. */
|
|
14
18
|
region: string;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
|
-
* Per-isolate cache
|
|
19
|
-
*
|
|
20
|
-
*
|
|
22
|
+
* Per-isolate cache for the fetched secret.
|
|
23
|
+
*
|
|
24
|
+
* @remarks
|
|
25
|
+
* Secrets Manager is queried at most once per isolate. The entry is keyed by
|
|
26
|
+
* `region:accessKeyId:secretId`, so rotating credentials triggers a fresh fetch. The in-flight promise
|
|
27
|
+
* itself is cached so that concurrent first-time callers share a single request. On rejection the cache
|
|
28
|
+
* is cleared so a failed fetch can be retried.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
21
31
|
*/
|
|
22
32
|
let cache: { key: string; value: Promise<unknown> } | null = null;
|
|
23
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Fetch and parse a secret from AWS Secrets Manager, caching the result per isolate.
|
|
36
|
+
*
|
|
37
|
+
* Issues a `GetSecretValue` call to Secrets Manager via a SigV4-signed `fetch` (using aws4fetch), with no
|
|
38
|
+
* AWS SDK involved. The parsed `SecretString` is cached per isolate keyed by region, access key ID, and
|
|
39
|
+
* secret ID; concurrent first-time callers share one in-flight request, and a rejected fetch clears the
|
|
40
|
+
* cache entry so the next call retries.
|
|
41
|
+
*
|
|
42
|
+
* @typeParam T - The shape of the JSON-parsed secret payload, supplied by the caller.
|
|
43
|
+
* @param options - AWS credentials and region used to sign the request.
|
|
44
|
+
* @param secretId - The Secrets Manager secret ID or ARN to retrieve.
|
|
45
|
+
* @returns The parsed secret value cast to `T`.
|
|
46
|
+
* @throws Error When the Secrets Manager response is not OK, or when it contains no `SecretString`.
|
|
47
|
+
* @example
|
|
48
|
+
* ```ts
|
|
49
|
+
* interface DbSecret {
|
|
50
|
+
* username: string;
|
|
51
|
+
* password: string;
|
|
52
|
+
* }
|
|
53
|
+
*
|
|
54
|
+
* const secret = await getAuthenticationSecret<DbSecret>(
|
|
55
|
+
* {
|
|
56
|
+
* accessKeyId: env.AWS_ACCESS_KEY_ID,
|
|
57
|
+
* secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
|
|
58
|
+
* region: 'ap-northeast-1',
|
|
59
|
+
* },
|
|
60
|
+
* 'prod/db/credentials',
|
|
61
|
+
* );
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
24
64
|
export function getAuthenticationSecret<T>(options: AwsSecretsOptions, secretId: string): Promise<T> {
|
|
25
65
|
const key = `${options.region}:${options.accessKeyId}:${secretId}`;
|
|
26
66
|
if (cache?.key !== key) {
|
|
@@ -33,6 +73,15 @@ export function getAuthenticationSecret<T>(options: AwsSecretsOptions, secretId:
|
|
|
33
73
|
return cache.value as Promise<T>;
|
|
34
74
|
}
|
|
35
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Perform the SigV4-signed `GetSecretValue` request and parse the returned `SecretString`.
|
|
78
|
+
*
|
|
79
|
+
* @param options - AWS credentials and region used to sign the request.
|
|
80
|
+
* @param secretId - The Secrets Manager secret ID or ARN to retrieve.
|
|
81
|
+
* @returns The JSON-parsed secret payload.
|
|
82
|
+
* @throws Error When the response is not OK, or when it contains no `SecretString`.
|
|
83
|
+
* @internal
|
|
84
|
+
*/
|
|
36
85
|
async function fetchSecret(options: AwsSecretsOptions, secretId: string): Promise<unknown> {
|
|
37
86
|
const aws = new AwsClient({
|
|
38
87
|
accessKeyId: options.accessKeyId,
|
package/src/cache/kv-cache.ts
CHANGED
|
@@ -1,30 +1,93 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* 同一 DB を参照する透過キャッシュなので `/api` とレスポンスは一致する(perf 層であり parity に影響しない)。
|
|
2
|
+
* Cache-aside helper backed by Cloudflare Workers KV.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* KV
|
|
4
|
+
* Provides a thin, JSON-serializing wrapper around a {@link KVNamespace} for the common
|
|
5
|
+
* "look in cache, fall back to the source of truth" pattern. Reads and writes are best-effort:
|
|
6
|
+
* any KV error, serialization failure, or oversized key is swallowed so callers transparently
|
|
7
|
+
* fall through to their backing store instead of throwing.
|
|
8
|
+
*
|
|
9
|
+
* Cache keys are namespaced as `<appName><version><table>_<type>_<id>`, where a string `id` is
|
|
10
|
+
* hashed with SHA-256 (hex) and a numeric `id` is used verbatim.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* Workers KV enforces a 60-second minimum on `expirationTtl`, so every write clamps its lifetime
|
|
14
|
+
* up to at least {@link KVCacheOptions.minTtlSeconds} (60 by default). Keys whose UTF-8 byte length
|
|
15
|
+
* exceeds the KV 1024-byte limit are skipped entirely, leaving the value uncached.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const cache = new KVCache(env.KV, { appName: 'myapp' });
|
|
20
|
+
* const cached = await cache.get<User>('users', 'profile', userId);
|
|
21
|
+
* if (!cached) {
|
|
22
|
+
* const user = await db.loadUser(userId);
|
|
23
|
+
* await cache.set('users', 'profile', userId, user);
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
8
26
|
*/
|
|
9
27
|
|
|
10
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Minimal subset of `@cloudflare/workers-types`' `KVNamespace` used by {@link KVCache}.
|
|
30
|
+
*
|
|
31
|
+
* Declared locally so consumers are not forced to depend on `@cloudflare/workers-types`. Only the
|
|
32
|
+
* three operations the cache actually needs are modeled.
|
|
33
|
+
*/
|
|
11
34
|
export interface KVNamespace {
|
|
35
|
+
/**
|
|
36
|
+
* Read the string value stored under `key`.
|
|
37
|
+
*
|
|
38
|
+
* @param key - Fully namespaced cache key.
|
|
39
|
+
* @returns The stored value, or `null` when the key is absent or expired.
|
|
40
|
+
*/
|
|
12
41
|
get(key: string): Promise<string | null>;
|
|
42
|
+
/**
|
|
43
|
+
* Write `value` under `key`, optionally with a time-to-live.
|
|
44
|
+
*
|
|
45
|
+
* @param key - Fully namespaced cache key.
|
|
46
|
+
* @param value - String payload to store.
|
|
47
|
+
* @param options - Optional write options; `expirationTtl` is the lifetime in seconds.
|
|
48
|
+
* @returns A promise that resolves once the write is accepted.
|
|
49
|
+
*/
|
|
13
50
|
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Remove the entry stored under `key`.
|
|
53
|
+
*
|
|
54
|
+
* @param key - Fully namespaced cache key.
|
|
55
|
+
* @returns A promise that resolves once the delete is accepted.
|
|
56
|
+
*/
|
|
14
57
|
delete(key: string): Promise<void>;
|
|
15
58
|
}
|
|
16
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Configuration for a {@link KVCache} instance.
|
|
62
|
+
*/
|
|
17
63
|
export interface KVCacheOptions {
|
|
18
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Application-level key prefix used to isolate this app's entries within a shared namespace.
|
|
66
|
+
* For example `'myapp'`.
|
|
67
|
+
*/
|
|
19
68
|
appName: string;
|
|
20
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Schema/version prefix applied after {@link appName}, letting you invalidate every key at once
|
|
71
|
+
* by bumping it. Defaults to `'v8_'`.
|
|
72
|
+
*/
|
|
21
73
|
version?: string;
|
|
22
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Lower bound, in seconds, applied to every write's TTL. Matches the Workers KV 60-second
|
|
76
|
+
* minimum and defaults to `60`.
|
|
77
|
+
*/
|
|
23
78
|
minTtlSeconds?: number;
|
|
24
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* Default TTL, in seconds, used by {@link KVCache.set} when no per-call `lifetime` is supplied.
|
|
81
|
+
* Defaults to `600`.
|
|
82
|
+
*/
|
|
25
83
|
defaultLifetime?: number;
|
|
26
84
|
}
|
|
27
85
|
|
|
86
|
+
/**
|
|
87
|
+
* A single entry to store via {@link KVCache.setMany}.
|
|
88
|
+
*
|
|
89
|
+
* @internal
|
|
90
|
+
*/
|
|
28
91
|
interface CacheSetItem {
|
|
29
92
|
table: string;
|
|
30
93
|
type: string | number;
|
|
@@ -33,6 +96,11 @@ interface CacheSetItem {
|
|
|
33
96
|
lifetime?: number;
|
|
34
97
|
}
|
|
35
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Key coordinates identifying a single entry for {@link KVCache.getMany}.
|
|
101
|
+
*
|
|
102
|
+
* @internal
|
|
103
|
+
*/
|
|
36
104
|
interface CacheKeyItem {
|
|
37
105
|
table: string;
|
|
38
106
|
type: string | number;
|
|
@@ -41,11 +109,32 @@ interface CacheKeyItem {
|
|
|
41
109
|
|
|
42
110
|
const encoder = new TextEncoder();
|
|
43
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Compute the lowercase hex SHA-256 digest of a UTF-8 string.
|
|
114
|
+
*
|
|
115
|
+
* @param input - String to hash.
|
|
116
|
+
* @returns The 64-character hex-encoded digest.
|
|
117
|
+
* @internal
|
|
118
|
+
*/
|
|
44
119
|
async function sha256Hex(input: string): Promise<string> {
|
|
45
120
|
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(input));
|
|
46
121
|
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
47
122
|
}
|
|
48
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Cache-aside wrapper over a Workers {@link KVNamespace}.
|
|
126
|
+
*
|
|
127
|
+
* Serializes values to JSON, namespaces keys, and clamps TTLs to the KV minimum. All operations are
|
|
128
|
+
* fail-soft: errors are swallowed so a cache miss or backend failure degrades to a source-of-truth
|
|
129
|
+
* lookup rather than propagating.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```ts
|
|
133
|
+
* const cache = new KVCache(env.KV, { appName: 'myapp', defaultLifetime: 300 });
|
|
134
|
+
* await cache.set('users', 'profile', 42, { name: 'Ada' });
|
|
135
|
+
* const user = await cache.get<{ name: string }>('users', 'profile', 42);
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
49
138
|
export class KVCache {
|
|
50
139
|
readonly #kv: KVNamespace;
|
|
51
140
|
readonly #appName: string;
|
|
@@ -53,6 +142,12 @@ export class KVCache {
|
|
|
53
142
|
readonly #minTtl: number;
|
|
54
143
|
readonly #defaultLifetime: number;
|
|
55
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Create a cache bound to a specific KV namespace.
|
|
147
|
+
*
|
|
148
|
+
* @param kv - The Workers KV namespace that backs this cache.
|
|
149
|
+
* @param options - Key-prefix and TTL configuration; see {@link KVCacheOptions}.
|
|
150
|
+
*/
|
|
56
151
|
constructor(kv: KVNamespace, options: KVCacheOptions) {
|
|
57
152
|
this.#kv = kv;
|
|
58
153
|
this.#appName = options.appName;
|
|
@@ -61,16 +156,41 @@ export class KVCache {
|
|
|
61
156
|
this.#defaultLifetime = options.defaultLifetime ?? 600;
|
|
62
157
|
}
|
|
63
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Build the fully namespaced KV key for the given coordinates.
|
|
161
|
+
*
|
|
162
|
+
* A string `id` is hashed with SHA-256 (hex); a numeric `id` is used as-is.
|
|
163
|
+
*
|
|
164
|
+
* @param table - Logical table or entity name.
|
|
165
|
+
* @param type - Sub-key discriminator (e.g. lookup variant).
|
|
166
|
+
* @param id - Entity identifier; strings are hashed, numbers used verbatim.
|
|
167
|
+
* @returns The key, or `undefined` when it would exceed the KV 1024-byte limit.
|
|
168
|
+
* @internal
|
|
169
|
+
*/
|
|
64
170
|
async #buildKey(table: string, type: string | number, id: string | number): Promise<string | undefined> {
|
|
65
171
|
const column = typeof id === 'string' ? await sha256Hex(id) : id;
|
|
66
172
|
const key = `${this.#appName}${this.#version}${table}_${type}_${column}`;
|
|
67
|
-
// KV
|
|
173
|
+
// KV keys are capped at 1024 bytes. Oversized keys are left uncached; cache-aside callers fall
|
|
174
|
+
// back to reading directly from their source of truth.
|
|
68
175
|
if (encoder.encode(key).byteLength > 1024) {
|
|
69
176
|
return undefined;
|
|
70
177
|
}
|
|
71
178
|
return key;
|
|
72
179
|
}
|
|
73
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Read and JSON-parse a cached value.
|
|
183
|
+
*
|
|
184
|
+
* @typeParam T - Expected shape of the cached value.
|
|
185
|
+
* @param table - Logical table or entity name.
|
|
186
|
+
* @param type - Sub-key discriminator.
|
|
187
|
+
* @param id - Entity identifier.
|
|
188
|
+
* @returns The parsed value, or `undefined` on a miss, oversized key, or any read/parse error.
|
|
189
|
+
* @example
|
|
190
|
+
* ```ts
|
|
191
|
+
* const user = await cache.get<User>('users', 'profile', userId);
|
|
192
|
+
* ```
|
|
193
|
+
*/
|
|
74
194
|
async get<T>(table: string, type: string | number, id: string | number): Promise<T | undefined> {
|
|
75
195
|
const key = await this.#buildKey(table, type, id);
|
|
76
196
|
if (!key) {
|
|
@@ -87,6 +207,24 @@ export class KVCache {
|
|
|
87
207
|
}
|
|
88
208
|
}
|
|
89
209
|
|
|
210
|
+
/**
|
|
211
|
+
* JSON-serialize and store a value.
|
|
212
|
+
*
|
|
213
|
+
* Falsy `data` is ignored. The effective TTL is `max(minTtlSeconds, lifetime ?? defaultLifetime)`,
|
|
214
|
+
* honoring the KV 60-second floor. Oversized keys and serialization/write failures are silently
|
|
215
|
+
* skipped.
|
|
216
|
+
*
|
|
217
|
+
* @param table - Logical table or entity name.
|
|
218
|
+
* @param type - Sub-key discriminator.
|
|
219
|
+
* @param id - Entity identifier.
|
|
220
|
+
* @param data - Value to cache; serialized with `JSON.stringify`.
|
|
221
|
+
* @param lifetime - Optional TTL in seconds; defaults to {@link KVCacheOptions.defaultLifetime}.
|
|
222
|
+
* @returns A promise that resolves once the write attempt completes.
|
|
223
|
+
* @example
|
|
224
|
+
* ```ts
|
|
225
|
+
* await cache.set('users', 'profile', userId, user, 300);
|
|
226
|
+
* ```
|
|
227
|
+
*/
|
|
90
228
|
async set(
|
|
91
229
|
table: string,
|
|
92
230
|
type: string | number,
|
|
@@ -111,6 +249,22 @@ export class KVCache {
|
|
|
111
249
|
await this.#kv.put(key, payload, { expirationTtl: ttl }).catch(() => undefined);
|
|
112
250
|
}
|
|
113
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Store many values concurrently.
|
|
254
|
+
*
|
|
255
|
+
* Each item is written via {@link KVCache.set}, so the same fail-soft and TTL rules apply per item.
|
|
256
|
+
* An empty array is a no-op.
|
|
257
|
+
*
|
|
258
|
+
* @param items - Entries to store; see {@link CacheSetItem}.
|
|
259
|
+
* @returns A promise that resolves once every write attempt completes.
|
|
260
|
+
* @example
|
|
261
|
+
* ```ts
|
|
262
|
+
* await cache.setMany([
|
|
263
|
+
* { table: 'users', type: 'profile', id: 1, data: userA },
|
|
264
|
+
* { table: 'users', type: 'profile', id: 2, data: userB, lifetime: 120 },
|
|
265
|
+
* ]);
|
|
266
|
+
* ```
|
|
267
|
+
*/
|
|
114
268
|
async setMany(items: CacheSetItem[]): Promise<void> {
|
|
115
269
|
if (items.length === 0) {
|
|
116
270
|
return;
|
|
@@ -118,12 +272,40 @@ export class KVCache {
|
|
|
118
272
|
await Promise.all(items.map((i) => this.set(i.table, i.type, i.id, i.data, i.lifetime)));
|
|
119
273
|
}
|
|
120
274
|
|
|
121
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Read many values concurrently.
|
|
277
|
+
*
|
|
278
|
+
* @typeParam T - Expected shape of each cached value.
|
|
279
|
+
* @param items - Key coordinates to look up; see {@link CacheKeyItem}.
|
|
280
|
+
* @returns One `{ id, value }` pair per input item, preserving order; `value` is `undefined` on miss.
|
|
281
|
+
* @example
|
|
282
|
+
* ```ts
|
|
283
|
+
* const rows = await cache.getMany<User>([
|
|
284
|
+
* { table: 'users', type: 'profile', id: 1 },
|
|
285
|
+
* { table: 'users', type: 'profile', id: 2 },
|
|
286
|
+
* ]);
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
// The generic `T` is the caller-specified return type, mirroring `get<T>` for ergonomics.
|
|
122
290
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
|
|
123
291
|
async getMany<T>(items: CacheKeyItem[]): Promise<{ id: string | number; value: T | undefined }[]> {
|
|
124
292
|
return Promise.all(items.map(async (i) => ({ id: i.id, value: await this.get<T>(i.table, i.type, i.id) })));
|
|
125
293
|
}
|
|
126
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Remove a cached entry.
|
|
297
|
+
*
|
|
298
|
+
* Oversized keys and delete failures are silently ignored.
|
|
299
|
+
*
|
|
300
|
+
* @param table - Logical table or entity name.
|
|
301
|
+
* @param type - Sub-key discriminator.
|
|
302
|
+
* @param id - Entity identifier.
|
|
303
|
+
* @returns A promise that resolves once the delete attempt completes.
|
|
304
|
+
* @example
|
|
305
|
+
* ```ts
|
|
306
|
+
* await cache.delete('users', 'profile', userId);
|
|
307
|
+
* ```
|
|
308
|
+
*/
|
|
127
309
|
async delete(table: string, type: string | number, id: string | number): Promise<void> {
|
|
128
310
|
const key = await this.#buildKey(table, type, id);
|
|
129
311
|
if (!key) {
|
package/src/db/connection.ts
CHANGED
|
@@ -2,30 +2,45 @@ import { createConnection } from 'mysql2/promise';
|
|
|
2
2
|
import type { Connection } from 'mysql2/promise';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Hyperdrive
|
|
5
|
+
* Minimal structural shape of a Cloudflare Hyperdrive binding.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Declared structurally to avoid a dependency on `@cloudflare/workers-types`; any object with these
|
|
9
|
+
* connection fields satisfies it.
|
|
6
10
|
*/
|
|
7
11
|
export interface HyperdriveLike {
|
|
12
|
+
/** Database host to connect to. */
|
|
8
13
|
host: string;
|
|
14
|
+
/** Database user. */
|
|
9
15
|
user: string;
|
|
16
|
+
/** Database password. */
|
|
10
17
|
password: string;
|
|
18
|
+
/** Database name. */
|
|
11
19
|
database: string;
|
|
20
|
+
/** Database port. */
|
|
12
21
|
port: number;
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
/**
|
|
16
|
-
* Hyperdrive
|
|
17
|
-
*
|
|
25
|
+
* Build mysql2 `createConnection` options from a Hyperdrive binding, applying the kit's defaults.
|
|
26
|
+
*
|
|
27
|
+
* @remarks
|
|
28
|
+
* Three defaults are applied and can each be overridden via `extra`:
|
|
18
29
|
*
|
|
19
|
-
* `
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
30
|
+
* - `disableEval: true` — `eval` is unavailable in the Workers runtime, so the driver's eval-based
|
|
31
|
+
* fast paths must be disabled.
|
|
32
|
+
* - `decimalNumbers: true` — return `DECIMAL`/`NEWDECIMAL` columns as JS `number` rather than
|
|
33
|
+
* strings, so raw-SQL reads and Drizzle's inferred types align on a single numeric domain type.
|
|
34
|
+
* This assumes no column's precision exceeds the JS safe-integer range.
|
|
35
|
+
* - `timezone: '+09:00'` — set the driver's session timezone to JST. mysql2 defaults to `'local'`,
|
|
36
|
+
* which is UTC in the Workers runtime; pinning the driver timezone keeps `datetime`/`timestamp`
|
|
37
|
+
* round-trips independent of the database's session timezone (only the internally stored UTC
|
|
38
|
+
* value differs, which is invisible to the application). Non-JST deployments can override this
|
|
39
|
+
* via `extra: { timezone: '...' }`.
|
|
23
40
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* 固定すれば round-trip の観測値は DB の session tz に非依存(内部格納 UTC 値だけ変わるが app 不可視)。
|
|
28
|
-
* 非 JST repo は `extra: { timezone: '...' }` で上書き可。
|
|
41
|
+
* @param hyperdrive - the Hyperdrive binding to derive connection fields from.
|
|
42
|
+
* @param extra - additional mysql2 options merged last, overriding the defaults above.
|
|
43
|
+
* @returns a plain options object to pass to mysql2 `createConnection`.
|
|
29
44
|
*/
|
|
30
45
|
export function hyperdriveConnectionOptions(
|
|
31
46
|
hyperdrive: HyperdriveLike,
|
|
@@ -44,13 +59,40 @@ export function hyperdriveConnectionOptions(
|
|
|
44
59
|
};
|
|
45
60
|
}
|
|
46
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Minimal structural shape of a Workers `ExecutionContext`, limited to `waitUntil`.
|
|
64
|
+
*
|
|
65
|
+
* @remarks
|
|
66
|
+
* Declared structurally to avoid a dependency on `@cloudflare/workers-types`.
|
|
67
|
+
*/
|
|
47
68
|
export interface ExecutionContextLike {
|
|
69
|
+
/** Extend the request's lifetime until `promise` settles (used to close connections after the response). */
|
|
48
70
|
waitUntil(promise: Promise<unknown>): void;
|
|
49
71
|
}
|
|
50
72
|
|
|
51
73
|
/**
|
|
52
|
-
* primary
|
|
53
|
-
*
|
|
74
|
+
* Open primary and replica connections, run `fn` with them, and close both afterwards.
|
|
75
|
+
*
|
|
76
|
+
* The connections are always closed in a `finally` block; closing is scheduled through
|
|
77
|
+
* `ctx.waitUntil` so it can complete after the response has been returned, without blocking it.
|
|
78
|
+
*
|
|
79
|
+
* @typeParam T - resolved value produced by `fn`.
|
|
80
|
+
* @param hyperdrives - the primary and replica Hyperdrive bindings to connect to.
|
|
81
|
+
* @param ctx - the execution context whose `waitUntil` defers connection teardown past the response.
|
|
82
|
+
* @param fn - callback invoked with the open `primary` and `replica` connections.
|
|
83
|
+
* @param connectionOptions - extra mysql2 options forwarded to {@link hyperdriveConnectionOptions}.
|
|
84
|
+
* @returns the value resolved by `fn`.
|
|
85
|
+
* @example
|
|
86
|
+
* ```ts
|
|
87
|
+
* const data = await withMysqlConnections(
|
|
88
|
+
* { primary: env.PRIMARY, replica: env.REPLICA },
|
|
89
|
+
* ctx,
|
|
90
|
+
* async ({ primary, replica }) => {
|
|
91
|
+
* const [rows] = await replica.query('SELECT 1');
|
|
92
|
+
* return rows;
|
|
93
|
+
* },
|
|
94
|
+
* );
|
|
95
|
+
* ```
|
|
54
96
|
*/
|
|
55
97
|
export async function withMysqlConnections<T>(
|
|
56
98
|
hyperdrives: { primary: HyperdriveLike; replica: HyperdriveLike },
|