@rdlabo/workers-hono-kit 0.9.0 → 0.9.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 CHANGED
@@ -44,6 +44,7 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
44
44
  > | `.` | `@rdlabo/workers-hono-kit` | Web-standard helpers (middleware, HTTP, Firebase, AWS, AI, Stripe, KV). |
45
45
  > | `./db` | `@rdlabo/workers-hono-kit/db` | MySQL data layer (mysql2 + Drizzle). |
46
46
  > | `./business-time` | `@rdlabo/workers-hono-kit/business-time` | JST business-time API (`toBusinessDateTime` / `normalizeBusinessDate` / `formatBusinessDateTime`, etc.). |
47
+ > | `./offline` | `@rdlabo/workers-hono-kit/offline` | Table-agnostic REST/DB method converters plus replica wire and clock helpers. |
47
48
  > | `./testing` | `@rdlabo/workers-hono-kit/testing` | Test helpers (mysql2 + Drizzle + fakes/fixtures). |
48
49
 
49
50
  ## API
@@ -86,6 +87,8 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
86
87
  | `configureHibernationAutoResponse` / `upgradeHibernationWebSocket` / `broadcastHibernationWebSockets` | Hibernation WebSocket room primitives: runtime ping/pong without waking JavaScript, attachment-before-accept upgrade, and broadcast through sockets restored by `getWebSockets()`. |
87
88
  | `acknowledgeHibernationWebSocketClose` / `closeHibernationWebSocket` | Safe close helpers, including normalization of reserved received-only close codes. |
88
89
  | `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. |
90
+ | `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. |
91
+ | `withIdempotencyHttpErrors(run)` | Maps only standard idempotency failures to 400/409/503 and rethrows unrelated failures. |
89
92
  | `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`). |
90
93
  | `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. |
91
94
  | `createStripeClient(secret, opts?)` / `verifyStripeWebhook(...)` / `CreateStripeClientOptions` | Workers-native Stripe client (fetch transport) + async webhook verification (SubtleCrypto). `apiVersion` optional (pin to a fixed Stripe API version). |
@@ -192,6 +195,70 @@ formatBusinessDateTime(now); // '2026-07-06T06:00:00'
192
195
  addBusinessDays('2026-07-06', 3); // '2026-07-09'
193
196
  ```
194
197
 
198
+ ### Offline replicas — `@rdlabo/workers-hono-kit/offline`
199
+
200
+ Table-agnostic building blocks for product-owned REST ↔ DB method converters and their offline
201
+ replica wire values. This subpath does not define table projections, Zod object shapes,
202
+ public-column allowlists, schema hashes, or domain rules; those remain in each Hono application.
203
+
204
+ This is an additive subpath: existing root and subpath exports are unchanged. Consumers can migrate
205
+ converter internals independently without changing REST payloads, schema hashes, or persisted SQLite
206
+ rows. For an `AUTO_INCREMENT` table, omit `id` from a create method's table scheme; keep the
207
+ client-generated UUID in `local_id` and keep `server_id` null until the server confirms its id.
208
+
209
+ | Export | Description |
210
+ | --- | --- |
211
+ | `defineRestDbMethodConverter(converter)` | Type a product-owned, pure `MethodScheme ↔ TableScheme` converter without hiding HTTP or persistence side effects. |
212
+ | `RestDbMethodConverter` | Product-owned converter contract. Its table scheme requires every represented table and column, including optional nullable/default keys from `$inferInsert`. |
213
+ | `toReplicaIsoDatetime(value)` | `Date` / datetime string → canonical UTC ISO-8601 wire value. |
214
+ | `toReplicaDateOnly(value)` | `Date` / date string / `null` → canonical `YYYY-MM-DD` / `null`. |
215
+ | `replicaTimestampMs(value)` | Replica datetime → epoch milliseconds for legacy DTOs. |
216
+ | `toTinyIntFlag(value)` / `fromTinyIntFlag(value)` | Boolean-like value ↔ numeric tinyint flag. |
217
+ | `replicaNowIso(clock?)` | Injectable wall clock → canonical UTC ISO-8601 wire value. |
218
+
219
+ ```ts
220
+ import {
221
+ defineRestDbMethodConverter,
222
+ replicaNowIso,
223
+ toReplicaIsoDatetime,
224
+ } from '@rdlabo/workers-hono-kit/offline';
225
+
226
+ type Tables = {
227
+ foods: FoodRow[];
228
+ allergens: AllergenRow[];
229
+ };
230
+
231
+ export const foodMethodConverter = defineRestDbMethodConverter<FoodMethodScheme, Tables>({
232
+ toMethodScheme: ({ foods, allergens }) => ({
233
+ ...foods[0],
234
+ allergens: allergens.map(({ value }) => value),
235
+ }),
236
+ toTableScheme: (method) => ({
237
+ foods: [{ id: method.id, memo: method.memo ?? null }],
238
+ allergens: method.allergens.map((value) => ({ threadId: method.id, value })),
239
+ }),
240
+ });
241
+ ```
242
+
243
+ `toTableScheme` requires every key represented by its DB row types. This includes nullable/default
244
+ columns that Drizzle marks optional in `$inferInsert`; write `memo: method.memo ?? null` instead of
245
+ omitting `memo`. If a REST method intentionally does not own an `AUTO_INCREMENT` column, remove it
246
+ from that method's product-owned table scheme explicitly:
247
+
248
+ ```ts
249
+ type CreateTables = {
250
+ foods: Omit<typeof foods.$inferInsert, 'id'>[];
251
+ };
252
+ ```
253
+
254
+ The converter then cannot demand or manufacture `id`; the server adds the generated id to the
255
+ confirmed response before it is stored as `server_id`.
256
+
257
+ ```ts
258
+ replicaNowIso(() => new Date('2026-07-23T10:00:00Z')); // '2026-07-23T10:00:00.000Z'
259
+ toReplicaIsoDatetime('2026-07-23T19:00:00+09:00'); // '2026-07-23T10:00:00.000Z'
260
+ ```
261
+
195
262
  ### Testing — `@rdlabo/workers-hono-kit/testing`
196
263
 
197
264
  Requires the `drizzle-orm` and `mysql2` peers. Consolidates duplicated test boilerplate.
@@ -0,0 +1,77 @@
1
+ /** Scalar values that may identify an idempotency scope. */
2
+ export type IdempotencyScopeValue = string | number;
3
+ /** A stable business scope for an idempotency key, such as user and tenant ids. */
4
+ export type IdempotencyScope = Readonly<Record<string, IdempotencyScopeValue>>;
5
+ /** Validated input persisted by an idempotency store. */
6
+ export interface IdempotencyInput<TScope extends IdempotencyScope = IdempotencyScope> {
7
+ /** Caller-supplied idempotency key. */
8
+ key: string;
9
+ /** SHA-256 of the canonical request payload. */
10
+ payloadHash: string;
11
+ /** Application-defined isolation scope. */
12
+ scope: TScope;
13
+ }
14
+ /** Options used to validate an idempotency key and hash its payload. */
15
+ export interface CreateIdempotencyInputOptions<TScope extends IdempotencyScope> {
16
+ /** Header value. `undefined` disables idempotency for backward compatibility. */
17
+ key: string | undefined;
18
+ /** Request payload whose semantic identity must remain stable across retries. */
19
+ payload: unknown;
20
+ /** Application-defined isolation scope. */
21
+ scope: TScope;
22
+ /** Maximum accepted key length. Defaults to 255. */
23
+ maxKeyLength?: number;
24
+ }
25
+ /** Raised when an idempotency key is empty or exceeds the configured limit. */
26
+ export declare class IdempotencyKeyValidationError extends Error {
27
+ constructor(message?: string);
28
+ }
29
+ /** Raised when a payload contains a value that cannot be represented by JSON. */
30
+ export declare class IdempotencyPayloadValidationError extends Error {
31
+ constructor(message?: string);
32
+ }
33
+ /** Raised when a key is reused with a different canonical payload. */
34
+ export declare class IdempotencyConflictError extends Error {
35
+ constructor(message?: string);
36
+ }
37
+ /** Raised when another request currently owns the same idempotency key. */
38
+ export declare class IdempotencyInFlightError extends Error {
39
+ constructor(message?: string);
40
+ }
41
+ /** Result returned by the store reservation step. */
42
+ export type IdempotencyReservation<TResponse> = {
43
+ kind: 'acquired';
44
+ } | {
45
+ kind: 'replay';
46
+ response: TResponse;
47
+ };
48
+ /** Transaction-bound persistence operations required by {@link runIdempotentMutation}. */
49
+ export interface IdempotentMutationStore<TScope extends IdempotencyScope, TResponse> {
50
+ /** Atomically reserve a key or return its previously completed response. */
51
+ reserve(input: IdempotencyInput<TScope>): Promise<IdempotencyReservation<TResponse>>;
52
+ /** Persist the mutation response in the same transaction as the domain write. */
53
+ complete(input: IdempotencyInput<TScope>, response: TResponse): Promise<void>;
54
+ }
55
+ /** Deterministically serialize JSON data with locale-independent, UTF-16 code-unit key ordering. */
56
+ export declare function canonicalJson(value: unknown): string;
57
+ /** Hash a value after canonical JSON serialization using the Workers Web Crypto API. */
58
+ export declare function sha256CanonicalJson(value: unknown): Promise<string>;
59
+ /** Validate an optional idempotency key and build its persistence input. */
60
+ export declare function createIdempotencyInput<TScope extends IdempotencyScope>(options: CreateIdempotencyInputOptions<TScope>): Promise<IdempotencyInput<TScope> | undefined>;
61
+ /**
62
+ * Execute a mutation with store-provided reservation and completion steps.
63
+ *
64
+ * @remarks
65
+ * The caller must bind `store` and `mutate` to the same database transaction. This function owns
66
+ * the state machine; the consuming application owns its schema and ORM adapter.
67
+ */
68
+ export declare function runIdempotentMutation<TScope extends IdempotencyScope, TResponse>(options: {
69
+ /** Optional input; omitted keys preserve legacy non-idempotent behavior. */
70
+ input: IdempotencyInput<TScope> | undefined;
71
+ /** Transaction-bound persistence adapter. */
72
+ store: IdempotentMutationStore<TScope, TResponse>;
73
+ /** Domain mutation executed only after this request acquires the key. */
74
+ mutate: () => Promise<TResponse>;
75
+ }): Promise<TResponse>;
76
+ /** Map standard idempotency failures to Hono HTTP exceptions without hiding unrelated errors. */
77
+ export declare function withIdempotencyHttpErrors<T>(run: () => Promise<T>): Promise<T>;
@@ -0,0 +1,152 @@
1
+ import { HTTPException } from 'hono/http-exception';
2
+ /** Raised when an idempotency key is empty or exceeds the configured limit. */
3
+ export class IdempotencyKeyValidationError extends Error {
4
+ constructor(message = 'Invalid Idempotency-Key') {
5
+ super(message);
6
+ this.name = 'IdempotencyKeyValidationError';
7
+ }
8
+ }
9
+ /** Raised when a payload contains a value that cannot be represented by JSON. */
10
+ export class IdempotencyPayloadValidationError extends Error {
11
+ constructor(message = 'Idempotency payload must contain only JSON values') {
12
+ super(message);
13
+ this.name = 'IdempotencyPayloadValidationError';
14
+ }
15
+ }
16
+ /** Raised when a key is reused with a different canonical payload. */
17
+ export class IdempotencyConflictError extends Error {
18
+ constructor(message = 'Idempotency-Key was already used with a different payload') {
19
+ super(message);
20
+ this.name = 'IdempotencyConflictError';
21
+ }
22
+ }
23
+ /** Raised when another request currently owns the same idempotency key. */
24
+ export class IdempotencyInFlightError extends Error {
25
+ constructor(message = 'Idempotent request is still processing') {
26
+ super(message);
27
+ this.name = 'IdempotencyInFlightError';
28
+ }
29
+ }
30
+ /** Deterministically serialize JSON data with locale-independent, UTF-16 code-unit key ordering. */
31
+ export function canonicalJson(value) {
32
+ try {
33
+ return canonicalJsonValue(value, new Set());
34
+ }
35
+ catch (error) {
36
+ if (error instanceof IdempotencyPayloadValidationError) {
37
+ throw error;
38
+ }
39
+ throw new IdempotencyPayloadValidationError();
40
+ }
41
+ }
42
+ function canonicalJsonValue(value, ancestors) {
43
+ if (Array.isArray(value)) {
44
+ const keys = Reflect.ownKeys(value);
45
+ const expectedKeys = new Set(['length', ...Array.from({ length: value.length }, (_, index) => String(index))]);
46
+ if (keys.length !== expectedKeys.size || keys.some((key) => typeof key !== 'string' || !expectedKeys.has(key))) {
47
+ throw new IdempotencyPayloadValidationError();
48
+ }
49
+ const items = Array.from({ length: value.length }, (_, index) => {
50
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
51
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
52
+ throw new IdempotencyPayloadValidationError();
53
+ }
54
+ return descriptor.value;
55
+ });
56
+ if (ancestors.has(value)) {
57
+ throw new IdempotencyPayloadValidationError();
58
+ }
59
+ ancestors.add(value);
60
+ const serialized = `[${items.map((item) => canonicalJsonValue(item, ancestors)).join(',')}]`;
61
+ ancestors.delete(value);
62
+ return serialized;
63
+ }
64
+ if (value !== null && typeof value === 'object') {
65
+ if (Object.getPrototypeOf(value) !== Object.prototype) {
66
+ throw new IdempotencyPayloadValidationError();
67
+ }
68
+ if (ancestors.has(value)) {
69
+ throw new IdempotencyPayloadValidationError();
70
+ }
71
+ const entries = Reflect.ownKeys(value).map((key) => {
72
+ if (typeof key !== 'string') {
73
+ throw new IdempotencyPayloadValidationError();
74
+ }
75
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
76
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
77
+ throw new IdempotencyPayloadValidationError();
78
+ }
79
+ return [key, descriptor.value];
80
+ });
81
+ ancestors.add(value);
82
+ const serialized = `{${entries
83
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
84
+ .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJsonValue(item, ancestors)}`)
85
+ .join(',')}}`;
86
+ ancestors.delete(value);
87
+ return serialized;
88
+ }
89
+ if (typeof value === 'number' && !Number.isFinite(value)) {
90
+ throw new IdempotencyPayloadValidationError();
91
+ }
92
+ const serialized = JSON.stringify(value);
93
+ if (typeof serialized !== 'string') {
94
+ throw new IdempotencyPayloadValidationError();
95
+ }
96
+ return serialized;
97
+ }
98
+ /** Hash a value after canonical JSON serialization using the Workers Web Crypto API. */
99
+ export async function sha256CanonicalJson(value) {
100
+ const bytes = new TextEncoder().encode(canonicalJson(value));
101
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
102
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
103
+ }
104
+ /** Validate an optional idempotency key and build its persistence input. */
105
+ export async function createIdempotencyInput(options) {
106
+ if (options.key === undefined) {
107
+ return undefined;
108
+ }
109
+ const maxKeyLength = options.maxKeyLength ?? 255;
110
+ if (options.key.length === 0 || options.key.length > maxKeyLength) {
111
+ throw new IdempotencyKeyValidationError();
112
+ }
113
+ return {
114
+ key: options.key,
115
+ payloadHash: await sha256CanonicalJson(options.payload),
116
+ scope: options.scope,
117
+ };
118
+ }
119
+ /**
120
+ * Execute a mutation with store-provided reservation and completion steps.
121
+ *
122
+ * @remarks
123
+ * The caller must bind `store` and `mutate` to the same database transaction. This function owns
124
+ * the state machine; the consuming application owns its schema and ORM adapter.
125
+ */
126
+ export async function runIdempotentMutation(options) {
127
+ if (!options.input) {
128
+ return options.mutate();
129
+ }
130
+ const reservation = await options.store.reserve(options.input);
131
+ if (reservation.kind === 'replay') {
132
+ return reservation.response;
133
+ }
134
+ const response = await options.mutate();
135
+ await options.store.complete(options.input, response);
136
+ return response;
137
+ }
138
+ /** Map standard idempotency failures to Hono HTTP exceptions without hiding unrelated errors. */
139
+ export async function withIdempotencyHttpErrors(run) {
140
+ return run().catch((error) => {
141
+ if (error instanceof IdempotencyKeyValidationError || error instanceof IdempotencyPayloadValidationError) {
142
+ throw new HTTPException(400, { message: error.message });
143
+ }
144
+ if (error instanceof IdempotencyConflictError) {
145
+ throw new HTTPException(409, { message: error.message });
146
+ }
147
+ if (error instanceof IdempotencyInFlightError) {
148
+ throw new HTTPException(503, { message: error.message });
149
+ }
150
+ throw error;
151
+ });
152
+ }
package/dist/index.d.ts CHANGED
@@ -47,6 +47,8 @@ export { defaultDefer, createWaitUntilDefer } from './http/defer.js';
47
47
  export type { DeferExecutor } from './http/defer.js';
48
48
  export { createSentryErrorReporter } from './http/http-error.js';
49
49
  export type { SentryExceptionReporterLike } from './http/http-error.js';
50
+ export { canonicalJson, createIdempotencyInput, IdempotencyConflictError, IdempotencyInFlightError, IdempotencyKeyValidationError, IdempotencyPayloadValidationError, runIdempotentMutation, sha256CanonicalJson, withIdempotencyHttpErrors, } from './idempotency/idempotency.js';
51
+ export type { CreateIdempotencyInputOptions, IdempotencyInput, IdempotencyReservation, IdempotencyScope, IdempotencyScopeValue, IdempotentMutationStore, } from './idempotency/idempotency.js';
50
52
  export { acknowledgeHibernationWebSocketClose, broadcastHibernationWebSockets, closeHibernationWebSocket, configureHibernationAutoResponse, upgradeHibernationWebSocket, } from './realtime/hibernation.js';
51
53
  export type { HibernationAutoResponseOptions, HibernationUpgradeOptions, HibernationWebSocketLike, HibernationWebSocketStateLike, WebSocketAutoResponsePairFactory, WebSocketPairFactory, } from './realtime/hibernation.js';
52
54
  export { isRetryableDurableObjectError, retryDurableObjectOperation } from './realtime/retry.js';
package/dist/index.js CHANGED
@@ -35,6 +35,8 @@ export { createAppErrorHandler } from './http/app-error-handler.js';
35
35
  export { normalizeTrailingSlash } from './http/trailing-slash.js';
36
36
  export { defaultDefer, createWaitUntilDefer } from './http/defer.js';
37
37
  export { createSentryErrorReporter } from './http/http-error.js';
38
+ // idempotency
39
+ export { canonicalJson, createIdempotencyInput, IdempotencyConflictError, IdempotencyInFlightError, IdempotencyKeyValidationError, IdempotencyPayloadValidationError, runIdempotentMutation, sha256CanonicalJson, withIdempotencyHttpErrors, } from './idempotency/idempotency.js';
38
40
  // realtime
39
41
  export { acknowledgeHibernationWebSocketClose, broadcastHibernationWebSockets, closeHibernationWebSocket, configureHibernationAutoResponse, upgradeHibernationWebSocket, } from './realtime/hibernation.js';
40
42
  export { isRetryableDurableObjectError, retryDurableObjectOperation } from './realtime/retry.js';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Read the current wall-clock instant in canonical replica wire form.
3
+ *
4
+ * @param clock - Injectable clock; defaults to the system wall clock.
5
+ * @returns The current instant as an ISO-8601 string.
6
+ */
7
+ export declare function replicaNowIso(clock?: () => Date): string;
@@ -0,0 +1,10 @@
1
+ import { toReplicaIsoDatetime } from './wire.js';
2
+ /**
3
+ * Read the current wall-clock instant in canonical replica wire form.
4
+ *
5
+ * @param clock - Injectable clock; defaults to the system wall clock.
6
+ * @returns The current instant as an ISO-8601 string.
7
+ */
8
+ export function replicaNowIso(clock = () => new Date()) {
9
+ return toReplicaIsoDatetime(clock());
10
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Table-agnostic helpers for offline replica converters.
3
+ *
4
+ * Product table projections, Zod object schemas, allowlists, and domain
5
+ * validation intentionally remain in each consuming Hono application.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ export { fromTinyIntFlag, replicaTimestampMs, toReplicaDateOnly, toReplicaIsoDatetime, toTinyIntFlag } from './wire.js';
10
+ export { replicaNowIso } from './clock.js';
11
+ export { defineRestDbMethodConverter } from './rest-db-method-converter.js';
12
+ export type { RestDbMethodConverter } from './rest-db-method-converter.js';
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Table-agnostic helpers for offline replica converters.
3
+ *
4
+ * Product table projections, Zod object schemas, allowlists, and domain
5
+ * validation intentionally remain in each consuming Hono application.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+ export { fromTinyIntFlag, replicaTimestampMs, toReplicaDateOnly, toReplicaIsoDatetime, toTinyIntFlag } from './wire.js';
10
+ export { replicaNowIso } from './clock.js';
11
+ export { defineRestDbMethodConverter } from './rest-db-method-converter.js';
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A product-owned table bundle used by one REST API method.
3
+ *
4
+ * Each property represents one DB table participating in the method. Products
5
+ * should use their exported Drizzle `$inferSelect` / `$inferInsert` types here;
6
+ * this package deliberately knows no table names or columns.
7
+ */
8
+ /** Require every column represented by a product DB row type. */
9
+ type CompleteDbRow<TRow> = {
10
+ [TKey in keyof TRow]-?: TRow[TKey];
11
+ };
12
+ type CompleteDbTableValue<TValue> = TValue extends (infer TRow)[] ? TRow extends object ? CompleteDbRow<TRow>[] : TValue : TValue extends readonly (infer TRow)[] ? TRow extends object ? readonly CompleteDbRow<TRow>[] : TValue : TValue extends object ? CompleteDbRow<TValue> : TValue;
13
+ /**
14
+ * Require every table key and every represented row column.
15
+ *
16
+ * This also makes optional `$inferInsert` columns explicit. A method that
17
+ * intentionally does not own a generated column must exclude it from its
18
+ * product-owned scheme first, for example `Omit<InsertRow, 'id'>`.
19
+ */
20
+ type CompleteRestDbTableScheme<TTableScheme extends object> = {
21
+ [TTableName in keyof TTableScheme]-?: CompleteDbTableValue<TTableScheme[TTableName]>;
22
+ };
23
+ /**
24
+ * Pure, bidirectional conversion between one REST method type and the DB table
25
+ * types participating in that method.
26
+ *
27
+ * Nullable/default DB columns remain required properties even when a Drizzle
28
+ * `$inferInsert` type marks them optional. Nullability does not make a column
29
+ * optional in the conversion contract.
30
+ */
31
+ export interface RestDbMethodConverter<TMethodScheme, TTableScheme extends object> {
32
+ toMethodScheme(tableScheme: Readonly<CompleteRestDbTableScheme<TTableScheme>>): TMethodScheme;
33
+ toTableScheme(methodScheme: Readonly<TMethodScheme>): CompleteRestDbTableScheme<TTableScheme>;
34
+ }
35
+ /**
36
+ * Define a product-specific REST ↔ DB converter with contextual return types.
37
+ *
38
+ * This is intentionally an identity function: conversion remains explicit,
39
+ * synchronous, and free of hidden persistence or HTTP side effects.
40
+ */
41
+ export declare function defineRestDbMethodConverter<TMethodScheme, TTableScheme extends object>(converter: RestDbMethodConverter<TMethodScheme, TTableScheme>): RestDbMethodConverter<TMethodScheme, TTableScheme>;
42
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Define a product-specific REST ↔ DB converter with contextual return types.
3
+ *
4
+ * This is intentionally an identity function: conversion remains explicit,
5
+ * synchronous, and free of hidden persistence or HTTP side effects.
6
+ */
7
+ export function defineRestDbMethodConverter(converter) {
8
+ return converter;
9
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Canonical UTC ISO-8601 wire form for an offline replica datetime.
3
+ *
4
+ * @param value - A valid instant represented as a `Date` or parseable string.
5
+ * @returns The instant as an ISO-8601 string.
6
+ * @throws RangeError when the input is not a valid instant.
7
+ */
8
+ export declare function toReplicaIsoDatetime(value: Date | string): string;
9
+ /**
10
+ * Canonical `YYYY-MM-DD` wire form for an offline replica date.
11
+ *
12
+ * A date-only string is treated as a calendar value and therefore is not shifted
13
+ * through a timezone. Datetime inputs are converted from their UTC instant.
14
+ *
15
+ * @param value - A date, datetime string, `Date`, or `null`.
16
+ * @returns A canonical date-only string, or `null`.
17
+ * @throws RangeError when the input is not a valid date.
18
+ */
19
+ export declare function toReplicaDateOnly(value: Date | string | null): string | null;
20
+ /**
21
+ * Convert a replica datetime to epoch milliseconds for legacy status DTOs.
22
+ *
23
+ * @param value - A valid instant represented as a `Date` or parseable string.
24
+ * @returns Epoch milliseconds.
25
+ * @throws RangeError when the input is not a valid instant.
26
+ */
27
+ export declare function replicaTimestampMs(value: Date | string): number;
28
+ /**
29
+ * Convert a boolean-like value to a MySQL/SQLite tinyint flag.
30
+ *
31
+ * @param value - Boolean or numeric truth value.
32
+ * @returns `1` for truthy values and `0` otherwise.
33
+ */
34
+ export declare function toTinyIntFlag(value: boolean | number): 0 | 1;
35
+ /**
36
+ * Convert a MySQL/SQLite tinyint flag to a boolean.
37
+ *
38
+ * @param value - Numeric flag.
39
+ * @returns `false` only for zero.
40
+ */
41
+ export declare function fromTinyIntFlag(value: number): boolean;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Canonical UTC ISO-8601 wire form for an offline replica datetime.
3
+ *
4
+ * @param value - A valid instant represented as a `Date` or parseable string.
5
+ * @returns The instant as an ISO-8601 string.
6
+ * @throws RangeError when the input is not a valid instant.
7
+ */
8
+ export function toReplicaIsoDatetime(value) {
9
+ const instant = value instanceof Date ? value : new Date(value);
10
+ if (Number.isNaN(instant.getTime())) {
11
+ throw new RangeError(`Invalid replica datetime: ${String(value)}`);
12
+ }
13
+ return instant.toISOString();
14
+ }
15
+ /**
16
+ * Canonical `YYYY-MM-DD` wire form for an offline replica date.
17
+ *
18
+ * A date-only string is treated as a calendar value and therefore is not shifted
19
+ * through a timezone. Datetime inputs are converted from their UTC instant.
20
+ *
21
+ * @param value - A date, datetime string, `Date`, or `null`.
22
+ * @returns A canonical date-only string, or `null`.
23
+ * @throws RangeError when the input is not a valid date.
24
+ */
25
+ export function toReplicaDateOnly(value) {
26
+ if (value === null) {
27
+ return null;
28
+ }
29
+ if (value instanceof Date) {
30
+ if (Number.isNaN(value.getTime())) {
31
+ throw new RangeError(`Invalid replica date: ${String(value)}`);
32
+ }
33
+ return value.toISOString().slice(0, 10);
34
+ }
35
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
36
+ const canonicalDate = new Date(`${value}T00:00:00.000Z`);
37
+ if (Number.isNaN(canonicalDate.getTime()) || canonicalDate.toISOString().slice(0, 10) !== value) {
38
+ throw new RangeError(`Invalid replica date: ${value}`);
39
+ }
40
+ return value;
41
+ }
42
+ const instant = new Date(value);
43
+ if (Number.isNaN(instant.getTime())) {
44
+ throw new RangeError(`Invalid replica date: ${value}`);
45
+ }
46
+ return instant.toISOString().slice(0, 10);
47
+ }
48
+ /**
49
+ * Convert a replica datetime to epoch milliseconds for legacy status DTOs.
50
+ *
51
+ * @param value - A valid instant represented as a `Date` or parseable string.
52
+ * @returns Epoch milliseconds.
53
+ * @throws RangeError when the input is not a valid instant.
54
+ */
55
+ export function replicaTimestampMs(value) {
56
+ const instant = value instanceof Date ? value : new Date(value);
57
+ if (Number.isNaN(instant.getTime())) {
58
+ throw new RangeError(`Invalid replica timestamp: ${String(value)}`);
59
+ }
60
+ return instant.getTime();
61
+ }
62
+ /**
63
+ * Convert a boolean-like value to a MySQL/SQLite tinyint flag.
64
+ *
65
+ * @param value - Boolean or numeric truth value.
66
+ * @returns `1` for truthy values and `0` otherwise.
67
+ */
68
+ export function toTinyIntFlag(value) {
69
+ return value ? 1 : 0;
70
+ }
71
+ /**
72
+ * Convert a MySQL/SQLite tinyint flag to a boolean.
73
+ *
74
+ * @param value - Numeric flag.
75
+ * @returns `false` only for zero.
76
+ */
77
+ export function fromTinyIntFlag(value) {
78
+ return value !== 0;
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -61,6 +61,11 @@
61
61
  "import": "./dist/business-time/index.js",
62
62
  "default": "./dist/business-time/index.js"
63
63
  },
64
+ "./offline": {
65
+ "types": "./dist/offline/index.d.ts",
66
+ "import": "./dist/offline/index.js",
67
+ "default": "./dist/offline/index.js"
68
+ },
64
69
  "./realtime": {
65
70
  "types": "./dist/realtime/index.d.ts",
66
71
  "import": "./dist/realtime/index.js",