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