@setu-ts/cloudflare-plugin
v0.11.0
Published
Cloudflare bindings: KV, R2, D1, Queues, Cron, Cache API, and Durable Objects
Readme
@setu-ts/cloudflare-plugin
Cloudflare Workers platform bindings for Setu-TS.
The framework has served traffic on Workers since the Hono migration, but had no way to reach the
platform's own primitives. This plugin publishes a Worker's bindings under CAPABILITIES.CLOUDFLARE
and, optionally, serves the committed cache, storage, queue, and messaging capabilities from KV, R2,
and Cloudflare Queues. It also ships the queue and scheduled handler exports a Worker needs, and
a response cache over the platform's own edge cache.
Zero npm dependencies. Nothing in this package imports cloudflare:workers — the application
passes env in, which keeps the package type-checkable on Deno, Node, and Bun, and trivially
testable with a plain object.
Installation
npm install @setu-ts/cloudflare-plugin
# Deno: deno add npm:@setu-ts/cloudflare-pluginUsage
import { env, waitUntil } from 'cloudflare:workers';
import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { CloudflarePlugin } from '@setu-ts/cloudflare-plugin';
import { CAPABILITIES, type ICacheStore } from '@setu-ts/common';
import type { ICloudflareBindings } from '@setu-ts/cloudflare-plugin';
const app = createApplication({
plugins: [
// Populates runtime.env with the Worker's string variables and secrets.
RuntimePlugin({ env }),
CloudflarePlugin({
env,
waitUntil,
cache: { binding: 'CACHE_KV', prefix: 'cache:' },
storage: { binding: 'UPLOADS' },
requireBindings: ['CACHE_KV', 'UPLOADS'],
}),
],
});
app.router.get('/settings', async (ctx) => {
const cache = ctx.services.get<ICacheStore>(CAPABILITIES.CACHE);
const cf = ctx.services.get<ICloudflareBindings>(CAPABILITIES.CLOUDFLARE);
const cached = await cache.get<Settings>('settings');
if (cached !== null) return ctx.response.json(cached);
const settings = await loadSettings(cf.d1('DB'));
await cache.set('settings', settings, 30);
cf.waitUntil(recordCacheMiss()); // finishes after the response
return ctx.response.json(settings);
});
export default { fetch: app.fetch };setu new --runtime cloudflare-workers scaffolds the env wiring and a wrangler.toml with
commented binding stanzas.
Requirements
compatibility_date must be 2025-08-08 or later — that is when Cloudflare shipped
import { waitUntil } from 'cloudflare:workers'. env has been importable at module scope for
longer; only waitUntil needs the newer date.
Options
| Option | Type | Default | Description |
| -------------------------------- | ------------------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| env | Record<string,unknown> | — | Required. The Worker's env. |
| waitUntil | WaitUntilHost | — | The platform sink. Omit off Workers. Without it on Workers, work passed to bindings.waitUntil() may be cut off when the response is sent, and one warning says so at startup — pass import { waitUntil } from 'cloudflare:workers' from your entry through the factory. |
| requireBindings | string[] | [] | Bindings that must exist; register() throws naming absent ones. |
| cache.binding | string | — | KV namespace serving CAPABILITIES.CACHE. |
| cache.name | string | 'default' | Derives cache.<name> when not 'default'. |
| cache.prefix | string | — | Key prefix. Required to call clear(). |
| cache.defaultTtlSeconds | number | — | TTL applied when set omits one. |
| storage.binding | string | — | R2 bucket serving CAPABILITIES.STORAGE. |
| storage.name | string | 'default' | Derives storage.<name> when not 'default'. |
| storage.prefix | string | — | Object-key prefix. |
| queue.binding | string | — | Queues producer binding serving CAPABILITIES.QUEUE. |
| queue.name | string | 'default' | Derives queue.<name> when not 'default'. |
| queue.maxDelaySeconds | number | 86400 | A larger delayMs throws rather than being truncated. |
| messaging.binding | string | — | Queues producer binding serving CAPABILITIES.MESSAGING. |
| messaging.name | string | 'default' | Derives messaging.<name> when not 'default'. |
| messaging.rpc.binding | string | — | DO namespace serving RPC reply inboxes. Absent, RPC throws. |
| messaging.rpc.defaultTimeoutMs | number | 5000 | Reply budget when RequestOptions.timeoutMs is omitted. |
| durableObject.binding | string | — | DO namespace serving CAPABILITIES.REALTIME_BACKPLANE. |
| durableObject.name | string | 'default' | Derives realtime-backplane.<name> when not 'default'. |
| durableObject.topic | string | 'realtime' | The object every replica shares. Differ it per application. |
Queues, Cron Triggers, and the edge cache
Cloudflare invokes queue and scheduled as module-level exports — fetch is not involved —
so your Worker assembles them beside it:
import {
cacheApiMiddleware,
CloudflarePlugin,
createQueueHandler,
createScheduledHandler,
WorkersCron,
} from '@setu-ts/cloudflare-plugin';
import { CAPABILITIES, type IQueue } from '@setu-ts/common';
const app = createApplication({
plugins: [
RuntimePlugin({ env }),
CloudflarePlugin({ env, waitUntil, queue: { binding: 'JOBS' } }),
],
});
// A route cached in the datacenter it was served from.
app.router.get('/catalog', {
handler: listCatalog,
middleware: [cacheApiMiddleware({ ttlSeconds: 300 })],
});
await app.start();
const queue = app.services.get<IQueue>(CAPABILITIES.QUEUE);
queue.process<{ to: string }>(
'send-welcome',
async (job) => {
await mailer.send({ to: job.data.to, subject: 'Welcome', text: 'Welcome!' });
},
{ concurrency: 5 },
);
// The expression must match wrangler.toml `[triggers] crons` exactly.
// An unmatched trigger FAILS the invocation. A logger adds a structured
// warning naming the registered expressions; it never changes the outcome.
const cron = new WorkersCron({ logger });
cron.on('0 3 * * *', async () => {
await queue.add('rebuild-reports', {});
});
export default {
fetch: app.fetch,
queue: createQueueHandler(app),
scheduled: createScheduledHandler(cron),
};wrangler.toml needs the matching stanzas:
[[queues.producers]]
queue = "jobs"
binding = "JOBS"
[[queues.consumers]]
queue = "jobs"
[triggers]
crons = ["0 3 * * *"]Messaging
CAPABILITIES.MESSAGING over Cloudflare Queues, with request/reply through a Durable Object.
app.register(
CloudflarePlugin({
env,
messaging: { binding: 'MESSAGES', rpc: { binding: 'REPLY_INBOX' } },
}),
);
const broker = app.services.get<IMessageBroker>(CAPABILITIES.MESSAGING);
await broker.subscribe<{ id: string }>('user.created', async (user) => {
/* … */
});
await broker.respond<number, number>('double', (n) => n * 2);
// Consuming is a MODULE export. Combine with a job queue by routing on the
// queue name, which your `wrangler.toml` owns and this package cannot see.
export default { fetch: app.fetch, queue: createMessagingHandler(app) };[[queues.producers]]
binding = "MESSAGES"
queue = "messages"
# REQUIRED for request/reply: the platform default of 5s alone exhausts the
# default reply budget, so every request() would time out.
[[queues.consumers]]
queue = "messages"
max_batch_size = 1
max_batch_timeout = 0
[[durable_objects.bindings]]
name = "REPLY_INBOX"
class_name = "ReplyInboxObject"
[[migrations]]
tag = "v1"
new_classes = ["ReplyInboxObject"]The rpc arm needs a Durable Object class your application exports, delegating to
ReplyInboxObjectCore exactly as the realtime backplane's class does — see
the class you export.
Three things to know before you rely on it:
publishreaches one consumer Worker, not every subscriber in the cluster. Cloudflare allows exactly one active consumer per queue. Fan-out happens across the handlers registered inside that consumer; cross-service fan-out means one queue per consuming service.subscriberegisters, it does not start receiving. Delivery begins when the Worker exports the handler above and the queue is declared under[[queues.consumers]].- A publish nobody subscribed to is acked, not retried. Retrying ordinary pub/sub would burn the queue's retry budget and dead-letter every fire-and-forget message. An unreadable body is still retried, and a subscriber that throws is retried.
Without the rpc arm, request and respond throw CloudflareUnsupportedError naming the binding
to add — publish and subscribe are unaffected.
Sessions
SessionPluginOptions.store is read when the plugin is constructed, before any application
exists, so a store published in the registry could never reach it. Build one yourself:
import { createRuntimeServices } from '@setu-ts/runtime';
import { SessionPlugin } from '@setu-ts/session-plugin';
import { type IKvNamespace, KvSessionStore } from '@setu-ts/cloudflare-plugin';
const runtime = createRuntimeServices({ env });
SessionPlugin({
secret: String(env.SESSION_SECRET),
mode: 'sign', // the payload lives in KV, so the cookie holds only an opaque id
store: new KvSessionStore(env.SESSIONS as IKvNamespace, runtime),
});D1
D1Adapter implements the committed IDatabaseAdapter, so a Worker serves CAPABILITIES.DATABASE
through the ordinary repository and Unit-of-Work surface. Like the session store, you build it and
hand it over — DatabasePlugin's options are read before any application exists.
import { DatabasePlugin } from '@setu-ts/database-plugin';
import { D1Adapter, type ID1Database } from '@setu-ts/cloudflare-plugin';
DatabasePlugin({
type: 'custom',
adapter: new D1Adapter(env.DB as ID1Database, {
// Optional: entity name → table / primary key. Unmapped entities use their
// own name as the table and `id` as the key.
tables: { User: { table: 'users' } },
}),
});D1 has no interactive transaction. It rejects BEGIN TRANSACTION, and batch() is its only
unit of atomicity. db.transaction(...) therefore buffers every write and flushes it as one
batch() at commit — atomic, and rolled back whole if any statement fails. Two consequences to plan
around:
- Reads inside a transaction do not see that transaction's own writes. They run immediately against committed state. Read what you need first, then write.
create()inside a transaction needs an explicit primary key. A deferredINSERTcannot hand a generated key back to a caller awaitingcreate()before the flush, so it throws rather than returning a row with a missing id. Outside a transactioncreate()usesRETURNING *and gives you the real persisted row, generated columns included.
Values are always bound; table and column names are validated and quoted, and any statement that would exceed D1's 100-bound-parameter limit is refused with an error naming the count rather than failing inside D1.
Migrations stay a wrangler d1 migrations concern — the adapter has no migrate().
D1 honours the same two portable refusal rules @setu-ts/database-plugin answers: a primary-key
change through update (or updateWhere) is refused with PrimaryKeyChangeError (400) instead
of moving the row, and a cursor D1 cannot use — malformed, made under a different sort, or of the
wrong shape for the target — is refused with InvalidCursorError (400) before any query runs.
Both classes are exported from @setu-ts/common.
Durable Objects — realtime backplane and distributed lock
Both features need a Durable Object class your application exports, plus a wrangler stanza. No plugin option can export a class on your behalf, so this is wiring you write once.
The class you export
This package ships the behaviour as two plain cores. Your class extends DurableObject and
delegates — that keeps cloudflare:workers out of the package, which matters because the specifier
is unresolvable on every other runtime and would break type-checking everywhere.
import { DurableObject } from 'cloudflare:workers';
import { DistributedLockObjectCore, RealtimeBackplaneObjectCore } from '@setu-ts/cloudflare-plugin';
export class RealtimeBackplaneObject extends DurableObject {
#core = new RealtimeBackplaneObjectCore(this.ctx);
override fetch(request: Request): Promise<Response> {
return this.#core.fetch(request);
}
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
this.#core.webSocketMessage(ws, message);
}
webSocketClose(ws: WebSocket, code: number, reason: string): void {
this.#core.webSocketClose(ws, code, reason);
}
webSocketError(ws: WebSocket): void {
this.#core.webSocketError(ws);
}
}
export class DistributedLockObject extends DurableObject {
#core = new DistributedLockObjectCore(this.ctx);
override fetch(request: Request): Promise<Response> {
return this.#core.fetch(request);
}
}The wrangler stanza
[[durable_objects.bindings]]
name = "REALTIME"
class_name = "RealtimeBackplaneObject"
[[durable_objects.bindings]]
name = "LOCKS"
class_name = "DistributedLockObject"
[exports.RealtimeBackplaneObject]
type = "durable-object"
storage = "sqlite"
[exports.DistributedLockObject]
type = "durable-object"
storage = "sqlite"storage = "sqlite" is required on the Workers Free plan. The older migrations +
new_sqlite_classes flow still works, but a Worker can use only one of the two flows — do not mix
them.
Realtime backplane
CloudflarePlugin({ env, waitUntil, durableObject: { binding: 'REALTIME', topic: 'chat' } });That registers CAPABILITIES.REALTIME_BACKPLANE, which websocket-plugin and sse-plugin resolve
on their own — so ws.room('lobby') and sse.channel('news') start reaching clients on other
replicas with no further wiring. Register either this arm or RealtimeBackplanePlugin, never
both: the kernel rejects two providers of one capability token.
The guarantee is narrower than "a durable subscription", deliberately. A Worker isolate is evicted at Cloudflare's discretion and its outbound WebSockets go with it, so no Worker can hold a subscription indefinitely. What makes that sound rather than lossy is the coupling: the members the subscription serves are client WebSockets held by the same isolate, and an HTTP-triggered Worker stays alive as long as its clients stay connected — so losing the isolate loses the subscription and its members together. The socket opens lazily on first publish and reopens after any failure.
Two applications sharing one namespace must set different topic values, or each receives the
other's frames.
Subscriber throws and returned promise rejections are isolated through common's shared dispatch. The
default reporter reads the logger at call time: first failure per handler at warn, repeats at debug
with a count, and recovery at info. Errors use describeError, so CR/LF cannot forge log lines;
frame data is never logged. A directly constructed DurableObjectBackplane accepts
onHandlerError(error, frame) in DurableObjectBackplaneOptions to receive every failure instead
of default logging. Hook and logger failures are swallowed.
Distributed lock
DurableObjectLock implements scheduler-plugin's IDistributedLock seam, so the lock can be handed
to SchedulerPlugin on a Node/Deno replica set — the composition it exists for. Do not register
SchedulerPlugin on Workers: register() refuses there with a SchedulerUnavailableError, naming
WorkersCron and [triggers] crons as the replacement, because timers cannot fire on an isolate
that is evicted between invocations. Workers use WorkersCron, above.
// On a Node/Deno replica set — where SchedulerPlugin runs.
import { DurableObjectLock } from '@setu-ts/cloudflare-plugin';
import { SchedulerPlugin } from '@setu-ts/scheduler-plugin';
const lock = new DurableObjectLock(env.LOCKS as IDurableObjectNamespace, {
runtime,
keyPrefix: 'reports:',
});
// An injected lock wins outright — `enabled: true` is not needed.
app.register(SchedulerPlugin({ distributedLock: { lock } }));One Durable Object per lock key. Correctness comes from the platform rather than an algorithm: a Durable Object processes one event at a time and holds back delivery while a storage operation runs, so the read-compare-write is atomic with no transaction and no Redlock-style quorum. The holder is persisted in the object's storage, never in memory, because an object is evicted after 70–140 seconds of inactivity and a lock TTL routinely outlives that.
A non-2xx from the object throws rather than reporting "not acquired" — a 404 means the binding points at the wrong class, and folding that into contention would silently disable every scheduled job.
Behaviour worth knowing before you deploy
- KV rejects a TTL under 60 seconds.
ICacheStore.setaccepts any TTL, so the value carries its own deadline: a 5-second entry reads as a miss on time even though the key survives up to a minute longer. Short TTLs are correct; they are just not free of storage. - KV writes are eventually consistent. This suits read-heavy caching, not coordination. Reach for a Durable Object when you need a consistent read after write.
- A read never deletes a key the store does not own, and a deliberately cached
nullsurvives a read — negative caching works.getstill answersnullfor it, becauseICacheStorehas no way to distinguish a cached absence from a miss, buthasanddeletereport it as present. clear()needs a prefix and costs one delete per key. The binding has no bulk delete, so the sweep pageslist(1000 keys at a time) and deletes each one. Without a prefix it would delete keys the store does not own, so it throws instead.- R2 cannot presign.
getSignedUrlrejects. Serve the object through a route (getStreamgives you a zero-copy download) or put a custom domain in front of the bucket. - Binding methods only work inside a request. Cloudflare prohibits I/O in global scope. The
plugin holds bindings at
register()and never reads through them there, and the health indicator performs no binding I/O. - An unroutable queue message is retried, never acked. A body that is not one of this package's
job envelopes, or a job name with no registered processor, goes back for redelivery and is
reported through the logger. Acking would discard it permanently and silently. Your queue's
max_retriesand dead-letter settings inwrangler.tomlthen decide what happens. queue.addRecurringthrows. Cloudflare Queues has no recurring message. Declare the schedule in[triggers] cronsand let aWorkersCronhandler enqueue the work.- There is no
IScheduleron Workers, deliberately.everyanddelayarm timers, and the isolate is evicted between invocations, so they would silently never fire;pause/resume/removeneed state that does not survive an invocation; andgetNextRunis owned bywrangler.toml.WorkersCronis a small honest surface instead of anISchedulerwhere six of eight methods throw. - A cron expression registered here but absent from
wrangler.tomlnever fires. Nothing in the process can read that file.cron.expressions()lets you assert your own coverage. An unmatched trigger fails the invocation; the logger you pass toWorkersCronadds a structured warning naming the registered expressions, but the outcome is the same without one. Matching is exact — whitespace is not normalized. - Only GET requests touch the edge cache. The cache key is a URL, which the Cache API resolves
as a GET request, so a
POSTto a cached path would otherwise be served the cached GET body and never reach your handler. Non-GET requests pass through withX-Cache-Api: BYPASS. This matters most if you add the middleware globally rather than to one GET route. - A failed cache write never fails your response.
Cache.putrejects for an oversized body or a quota error; the response is already built by then, so the failure is reported and the request succeeds uncached. - The write lands with or without a
waitUntilhost. When the plugin received no host the middleware awaitsCache.putitself before the response returns — the write delays the response instead of being abandoned when the isolate is released (V9-23). PassingwaitUntilmoves the write off the response path; the plugin warns at startup when it is missing on Workers. caches.defaultis per-datacenter.cacheApiMiddlewareis a latency optimisation, not a shared store: a hit in one colo says nothing about another, and adeletedoes not evict globally. It reports underX-Cache-Api, so it composes withcache-plugin'scacheMiddleware(which reports underX-Cacheand reads a store every colo shares) rather than colliding with it.- The default key carries the resolved tenant and locale. The key is a URL string the platform
matches with no request headers, so
Varycannot separate entries here;ctx.request.tenantandctx.request.localeare appended to the URL text assetu-cache-tenantandsetu-cache-localeparameters instead. The middleware must run after the tenant and locale middleware (globally, at a higher priority number than theirs — 40 and 45 by default, or whatevermiddlewarePriorityandmiddleware.priorityare set to), and a request no key can keep apart from another — a fragment, un-normalized URL text, an unencodable id, or a reserved parameter on a request that lacks the value — is served uncached. A customkeymust include both itself. - The edge cache refuses some responses, and the middleware skips them rather than failing.
Non-GET, 206,
Vary: *, and an unclearedSet-Cookieall makecaches.default.putthrow; those are checked first.Cache-Control: private=Set-Cookieis the platform's opt-in. Streaming responses are never cached. - A cache HIT is replayed as a stream, so
app.inject()cannot read its body — drive cached routes in tests withapp.fetchand a webRequest, which is what a Worker invokes anyway. - Not verified against a live Worker. Every binding is exercised against a fake built from the
documented signatures, including KV's 60-second floor and R2's void
delete. CI has no Cloudflare account.
Not in this package
- Hyperdrive, Vectorize, Workers AI, Analytics Engine. Reachable now through
bindings.get<T>('NAME'); each becomes a first-class port only when an application needs one.
Documentation
License
MIT
Exports
| Export | Kind |
| ----------------------------------------- | --------- |
| assessCacheability | function |
| asUpgradeResponse | function |
| cacheApiMiddleware | function |
| CloudflarePlugin | function |
| createDefaultDurableObjectWebSocketHost | function |
| createMessagingHandler | function |
| createQueueHandler | function |
| createScheduledHandler | function |
| isD1Database | function |
| isDurableObjectNamespace | function |
| isKvNamespace | function |
| isQueueProducer | function |
| isR2Bucket | function |
| CloudflareBindingMissingError | class |
| CloudflareObjectNotFoundError | class |
| CloudflareRemoteHandlerError | class |
| CloudflareRequestTimeoutError | class |
| CloudflareUnsupportedError | class |
| D1Adapter | class |
| DistributedLockObjectCore | class |
| DurableObjectBackplane | class |
| DurableObjectIdempotencyStore | class |
| DurableObjectLock | class |
| IdempotencyObjectCore | class |
| KvCacheStore | class |
| KvSessionStore | class |
| R2Storage | class |
| RealtimeBackplaneObjectCore | class |
| ReplyInboxObjectCore | class |
| WorkersBroker | class |
| WorkersCron | class |
| WorkersQueue | class |
| BrokerRuntime | interface |
| CacheabilityInput | interface |
| CacheApiMiddlewareOptions | interface |
| CacheClock | interface |
| CloudflarePluginOptions | interface |
| D1AdapterOptions | interface |
| D1EntityMapping | interface |
| D1Result | interface |
| DistributedLockObjectCoreOptions | interface |
| DurableObjectArm | interface |
| DurableObjectBackplaneOptions | interface |
| DurableObjectIdempotencyStoreOptions | interface |
| DurableObjectLockOptions | interface |
| DurableObjectMessageEvent | interface |
| DurableObjectUpgradeResponse | interface |
| DurableObjectWebSocketHost | interface |
| DurableObjectWebSocketPair | interface |
| ICacheApi | interface |
| ICloudflareBindings | interface |
| ID1Database | interface |
| ID1PreparedStatement | interface |
| IdempotencyObjectCoreOptions | interface |
| IDurableObjectClientSocket | interface |
| IDurableObjectNamespace | interface |
| IDurableObjectState | interface |
| IDurableObjectStorage | interface |
| IDurableObjectWebSocket | interface |
| IIdempotencyObjectState | interface |
| IKvNamespace | interface |
| IQueueMessage | interface |
| IQueueMessageBatch | interface |
| IQueueProducer | interface |
| IR2Bucket | interface |
| IR2Object | interface |
| IR2ObjectBody | interface |
| IScheduledController | interface |
| IServiceBinding | interface |
| JobIdSource | interface |
| KvCacheOptions | interface |
| KvCacheStoreOptions | interface |
| KvListOptions | interface |
| KvListResult | interface |
| KvPutOptions | interface |
| KvSessionStoreOptions | interface |
| MessagingHandlerOptions | interface |
| QueueHandlerOptions | interface |
| QueueSendOptions | interface |
| R2PutOptions | interface |
| R2StorageArm | interface |
| R2StorageOptions | interface |
| RealtimeBackplaneObjectCoreOptions | interface |
| ReplyInboxBinding | interface |
| ReplyInboxObjectCoreOptions | interface |
| WorkersBrokerOptions | interface |
| WorkersCronOptions | interface |
| WorkersMessagingArm | interface |
| WorkersMessagingRpcArm | interface |
| WorkersQueueArm | interface |
| WorkersQueueOptions | interface |
| CacheRefusal | type |
| CloudflareWorkerEnv | type |
| CronHandler | type |
| LoggerSource | type |
| MessagingHandler | type |
| QueueHandler | type |
| ScheduledHandler | type |
| WaitUntilHost | type |
Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it
drifts.
The Durable Object idempotency store (M109a)
One Durable Object per idempotency store key — the tier-B store @setu-ts/idempotency-plugin can
use on Workers. The application exports the DO class and hands the store to the plugin; the object
owns the record, its lease and its alarm.
# wrangler.toml
[[durable_objects.bindings]]
name = "IDEMPOTENCY"
class_name = "IdempotencyObject"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["IdempotencyObject"]import { DurableObject } from 'cloudflare:workers';
import { DurableObjectIdempotencyStore, IdempotencyObjectCore } from '@setu-ts/cloudflare-plugin';
import { IdempotencyPlugin } from '@setu-ts/idempotency-plugin';
// The Durable Object side — the class the binding names.
export class IdempotencyObject extends DurableObject {
#core = new IdempotencyObjectCore(this.ctx);
override fetch(request: Request): Promise<Response> {
return this.#core.fetch(request);
}
override alarm(): Promise<void> {
return this.#core.alarm();
}
}
// The Worker side — one store, its `namespace` isolating this application's records.
const store = new DurableObjectIdempotencyStore(env.IDEMPOTENCY, { namespace: 'shop' });
app.register(IdempotencyPlugin({ store: { type: 'custom', store } }));The store takes namespace (required), keyPrefix (default 'idempotency:'), binding (for error
messages) and timeoutMs (default 5,000; 0 disables). It advertises maxRecordBytes of 120,000 —
under the KV-backed Durable Object value limit — and has no isHealthy, because probing it would
create and bill an object.
D1 payload refusals (M119e, 0.11.0)
D1 create, update and updateWhere refuse invalid payload identifiers or unknown payload
columns with UnknownPayloadFieldError from @setu-ts/common (400). The SQLite unknown-column
mapping applies only when the reported column belongs to the payload; precondition-column errors
keep their existing behavior. A STRICT-table type refusal becomes InvalidFieldValueError (400).
Both HTTP details are fixed and contain no field names or supplied values.
