@lenso/limits 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +352 -2
- package/dist/config.d.ts +5 -0
- package/dist/contracts.d.ts +60 -0
- package/dist/d1.d.ts +546 -0
- package/dist/d1.js +164 -0
- package/dist/index-bz4qnpx8.js +138 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +321 -0
- package/dist/memory.d.ts +5 -0
- package/dist/postgres.d.ts +211 -0
- package/dist/postgres.js +128 -0
- package/dist/service.d.ts +27 -0
- package/dist/sqlite.d.ts +426 -0
- package/dist/sqlite.js +107 -0
- package/dist/state.d.ts +25 -0
- package/migrations/0001_d1.sql +34 -0
- package/migrations/0001_postgres.sql +23 -0
- package/migrations/0001_sqlite.sql +23 -0
- package/package.json +58 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LioRael
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,353 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lenso/limits
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Request rate, periodic numeric quota, and weighted concurrency leases. These are
|
|
4
|
+
admission controls, not balances, money, billing, or an exactly-once system.
|
|
5
|
+
|
|
6
|
+
## Ordinary service
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { createLimits, createMemoryLimitStore } from "@lenso/limits";
|
|
10
|
+
|
|
11
|
+
const limits = createLimits({
|
|
12
|
+
store: createMemoryLimitStore(),
|
|
13
|
+
config: { failurePolicy: "throw" }, // required: "throw" | "deny" | "allow"
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Trusted application code derives tenant and subject after authentication.
|
|
17
|
+
const scope = { instance: "reports", tenant: tenantId, key: `run:${subjectId}` };
|
|
18
|
+
const rate = await limits.consumeRate({
|
|
19
|
+
scope,
|
|
20
|
+
capacity: 100,
|
|
21
|
+
quantity: 1,
|
|
22
|
+
periodMs: 60_000,
|
|
23
|
+
});
|
|
24
|
+
const quota = await limits.consumeQuota({
|
|
25
|
+
scope,
|
|
26
|
+
capacity: 1000,
|
|
27
|
+
quantity: 5,
|
|
28
|
+
periodMs: 86_400_000,
|
|
29
|
+
});
|
|
30
|
+
await limits.close(); // releases this service's leases, never closes its store
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Rate and quota occupy separate namespaces even with identical scopes. Both use
|
|
34
|
+
**fixed UTC epoch-aligned windows**:
|
|
35
|
+
`windowStart = floor(now / periodMs) * periodMs`. This is intentionally not a
|
|
36
|
+
sliding-window or token-bucket limiter: traffic can burst on both sides of a
|
|
37
|
+
window boundary. Periods are fixed elapsed durations, not calendar months,
|
|
38
|
+
customer billing cycles, or a caller-supplied reset timestamp.
|
|
39
|
+
|
|
40
|
+
`capacity` and `quantity` must be integers in `1..2147483647`; `periodMs` and
|
|
41
|
+
`ttlMs` must be integers in `1..31622400000` (366 days). Fractional, zero,
|
|
42
|
+
negative, nonfinite and out-of-range values fail. A valid quantity larger than
|
|
43
|
+
capacity is denied without consuming anything.
|
|
44
|
+
|
|
45
|
+
All admission results include `allowed`, `remaining`, `retryAfter`, and `reason`.
|
|
46
|
+
`retryAfter` is **milliseconds**, not an HTTP header:
|
|
47
|
+
|
|
48
|
+
- allowed: `0`;
|
|
49
|
+
- exhausted counter: time to the next window;
|
|
50
|
+
- exhausted lease capacity: earliest expiry that could fit this quantity,
|
|
51
|
+
assuming no renewals or earlier releases;
|
|
52
|
+
- quantity larger than capacity or unknown backend outcome: `null`.
|
|
53
|
+
|
|
54
|
+
For HTTP, the application can turn a finite value into Retry-After seconds with
|
|
55
|
+
`Math.ceil(retryAfter / 1000)`. It must not turn `null` into zero.
|
|
56
|
+
|
|
57
|
+
Policy is pinned per namespace/scope: changing counter capacity/period or lease
|
|
58
|
+
capacity returns `LimitError("policy-conflict")`. All workers must agree on
|
|
59
|
+
policies. Version a policy's application key deliberately when changing it;
|
|
60
|
+
doing so creates a new allowance, not a migration of already consumed quota.
|
|
61
|
+
|
|
62
|
+
## Modes and shared storage
|
|
63
|
+
|
|
64
|
+
`createMemoryLimitStore()` belongs to one store object in one process. Separate
|
|
65
|
+
objects, CLI invocations and processes have separate limits. There is no hidden
|
|
66
|
+
singleton, background timer or cluster coordination.
|
|
67
|
+
|
|
68
|
+
The optional `@lenso/limits/sqlite` entry accepts **native Bun SQLite Drizzle**.
|
|
69
|
+
Multiple processes on the **same host**, opening the same local database file,
|
|
70
|
+
share limits. The root entry does not import Drizzle, `bun:sqlite`, Auth, Web,
|
|
71
|
+
Manage or Tasks. Install `drizzle-orm@0.45.3` for a SQL adapter, not memory mode.
|
|
72
|
+
|
|
73
|
+
Apply `migrations/0001_sqlite.sql` through your explicit migration workflow
|
|
74
|
+
before starting consumers. Exported `limitSchema` can join the application's
|
|
75
|
+
Drizzle schema. Setup never creates tables or changes PRAGMAs.
|
|
76
|
+
The SQL file is also exported at
|
|
77
|
+
`@lenso/limits/migrations/0001_sqlite.sql`.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { createBunSqlitePlugin } from "@lenso/db/bun-sqlite";
|
|
81
|
+
import { createLimitsPlugin } from "@lenso/limits";
|
|
82
|
+
import { createSqliteLimitStore, limitSchema } from "@lenso/limits/sqlite";
|
|
83
|
+
|
|
84
|
+
const db = createBunSqlitePlugin({
|
|
85
|
+
id: "limit-db",
|
|
86
|
+
filename: "./state/limits.sqlite",
|
|
87
|
+
schema: limitSchema,
|
|
88
|
+
});
|
|
89
|
+
const limits = createLimitsPlugin({
|
|
90
|
+
id: "limits",
|
|
91
|
+
requires: [db],
|
|
92
|
+
config: { failurePolicy: "deny" },
|
|
93
|
+
connect: (context) => createSqliteLimitStore(context.get(db)),
|
|
94
|
+
});
|
|
95
|
+
// Install these exact objects. A consumer declares requires: [limits] and
|
|
96
|
+
// obtains context.get(limits); creating another object with the same ID is not DI.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The factory also accepts existing Config sources as `config: [source, ...]`;
|
|
100
|
+
`limitConfig` is its public Standard Schema/JSON Schema contract. The final
|
|
101
|
+
Config-bound plugin is the installed instance. Resource/plugin handles never
|
|
102
|
+
belong in serialized config. `scope.instance` names the logical application
|
|
103
|
+
policy namespace, **not** a per-process `context.instanceId`: giving each replica
|
|
104
|
+
a different scope intentionally isolates its allowance.
|
|
105
|
+
|
|
106
|
+
The application/DB owner should configure WAL and a suitable `busy_timeout`
|
|
107
|
+
on each connection through its existing DB setup. The adapter borrows the DB
|
|
108
|
+
and never closes it. SQLite lock contention remains a backend error after busy
|
|
109
|
+
handling, not an exhausted-limit result. The synchronous adapter blocks the
|
|
110
|
+
Bun event loop while waiting on locks; do not nest calls inside a deferred
|
|
111
|
+
application read transaction or long-running DB transaction.
|
|
112
|
+
|
|
113
|
+
Each mutation uses Drizzle's real `BEGIN IMMEDIATE` transaction: acquire the
|
|
114
|
+
SQLite writer lock, sample DB time, read/check/prune/update, then commit. The
|
|
115
|
+
callback is synchronous and never yields. Atomicity comes from that database
|
|
116
|
+
write transaction, not from JavaScript read/modify/write or an in-process lock.
|
|
117
|
+
The lease list is persisted as JSON per scope; this is intended for moderate
|
|
118
|
+
concurrency, not millions of concurrent leases. All writers must use the store
|
|
119
|
+
contract; direct table edits bypass its invariants.
|
|
120
|
+
|
|
121
|
+
Time is sampled once **after lock acquisition** from SQLite's UTC VFS clock.
|
|
122
|
+
Each bucket persists its highest observed time; backward jumps freeze progress
|
|
123
|
+
until the clock catches up rather than reopening consumed windows or reviving
|
|
124
|
+
expired leases. Forward jumps reset windows/expire leases sooner. Memory mode
|
|
125
|
+
uses `Date.now()` with the same per-bucket clamp; injected `now` is for tests.
|
|
126
|
+
|
|
127
|
+
SQLite remains local-file only: no cross-host/network-filesystem coordination.
|
|
128
|
+
PG and D1 use the separate adapters below, not casts of SQLite's database type.
|
|
129
|
+
No Redis dependency or universal database execution layer is introduced.
|
|
130
|
+
|
|
131
|
+
### PostgreSQL shared mode
|
|
132
|
+
|
|
133
|
+
`@lenso/limits/postgres` exports `createPostgresLimitStore` and
|
|
134
|
+
`postgresLimitSchema`, accepting native Drizzle `PgDatabase` types. The public
|
|
135
|
+
Bun-first resource pairs with it directly:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { createBunSqlPlugin } from "@lenso/db/bun-sql";
|
|
139
|
+
import { createLimitsPlugin } from "@lenso/limits";
|
|
140
|
+
import { createPostgresLimitStore, postgresLimitSchema } from "@lenso/limits/postgres";
|
|
141
|
+
|
|
142
|
+
const db = createBunSqlPlugin({
|
|
143
|
+
id: "limit-pg",
|
|
144
|
+
connection: databaseUrl, // Trusted application configuration, not request JSON.
|
|
145
|
+
schema: postgresLimitSchema,
|
|
146
|
+
});
|
|
147
|
+
const limits = createLimitsPlugin({
|
|
148
|
+
id: "limits",
|
|
149
|
+
requires: [db],
|
|
150
|
+
config: { failurePolicy: "deny" },
|
|
151
|
+
connect: (context) => createPostgresLimitStore(context.get(db)),
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Apply `@lenso/limits/migrations/0001_postgres.sql` explicitly. Counters use
|
|
156
|
+
BIGINT and concurrency buckets use JSONB; all values remain within the common
|
|
157
|
+
JavaScript integer bounds. There is no startup migration or new pool owned by
|
|
158
|
+
the store.
|
|
159
|
+
|
|
160
|
+
Each operation runs in a database transaction. Bucket creation uses
|
|
161
|
+
`INSERT ... ON CONFLICT DO NOTHING`, then `SELECT ... FOR UPDATE` serializes
|
|
162
|
+
changes to the same namespace/scope. Only **after acquiring that row lock**, a
|
|
163
|
+
fresh statement samples `clock_timestamp()`; `now()`/transaction-start time
|
|
164
|
+
would wrongly include time spent waiting. Shared state rules then update the
|
|
165
|
+
locked row before commit, including expiry pruning and weighted admission.
|
|
166
|
+
Different scopes are not held behind a single application/global lock.
|
|
167
|
+
|
|
168
|
+
Processes or hosts connected to the **same authoritative PG database** with the
|
|
169
|
+
same namespace/policies share admission state. The application's DB owner
|
|
170
|
+
configures connection TLS, timeouts and availability; the store borrows the
|
|
171
|
+
existing resource and does not change them. Lock/statement timeouts and
|
|
172
|
+
serialization errors reach the explicit failure policy; the package does not
|
|
173
|
+
automatically retry unknown writes. Use the primary database, not a read
|
|
174
|
+
replica. JSON lease buckets target moderate concurrency.
|
|
175
|
+
|
|
176
|
+
Tests run real PostgreSQL on loopback with independent clients and owned
|
|
177
|
+
temporary clusters. They verify row-lock waits, post-lock expiry checks,
|
|
178
|
+
scope isolation, timeout faults and borrowed-resource cleanup. They do not
|
|
179
|
+
simulate a production failover or claim tested multi-host networking.
|
|
180
|
+
|
|
181
|
+
### D1 and Workers shared mode
|
|
182
|
+
|
|
183
|
+
`@lenso/limits/d1` exports `createD1LimitStore` and `d1LimitSchema`, accepting
|
|
184
|
+
native `DrizzleD1Database`. Install the exact DB and limits instances inside the
|
|
185
|
+
application's existing `@lenso/workers` request assembly:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
189
|
+
import { createD1Plugin } from "@lenso/db/d1";
|
|
190
|
+
import { createLimitsPlugin } from "@lenso/limits";
|
|
191
|
+
import { createD1LimitStore, d1LimitSchema } from "@lenso/limits/d1";
|
|
192
|
+
|
|
193
|
+
function limitsForRequest(bindings: { DB: D1Database }) {
|
|
194
|
+
const db = createD1Plugin({ id: "limit-d1", binding: bindings.DB, schema: d1LimitSchema });
|
|
195
|
+
const limits = createLimitsPlugin({
|
|
196
|
+
id: "limits",
|
|
197
|
+
requires: [db],
|
|
198
|
+
config: { failurePolicy: "deny" },
|
|
199
|
+
connect: (context) => createD1LimitStore(context.get(db)),
|
|
200
|
+
});
|
|
201
|
+
return { plugins: [db, limits], limits };
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Apply `@lenso/limits/migrations/0001_d1.sql` with the existing D1 migration
|
|
206
|
+
workflow before serving requests. Its counter/bucket/lease tables are
|
|
207
|
+
D1-specific and are **not interchangeable** with the local SQLite migration.
|
|
208
|
+
The platform binding stays borrowed. The runtime graph imports no Bun database,
|
|
209
|
+
filesystem or PG driver, and no listener is started by the adapter.
|
|
210
|
+
|
|
211
|
+
D1 has no interactive transaction callback here. Each method sends a fixed
|
|
212
|
+
`db.batch` transaction: advance the bucket's persisted database time,
|
|
213
|
+
reset/prune as needed, perform guarded SQL admission, and return its snapshot.
|
|
214
|
+
Counter consumption is a conditional `UPDATE`; lease admission is
|
|
215
|
+
`INSERT ... SELECT` conditioned on the live quantity sum. JavaScript never
|
|
216
|
+
reads an allowance and writes back a proposed replacement. A zero-row
|
|
217
|
+
mutation means denial or policy mismatch, **not** a batch error, so every
|
|
218
|
+
mutating statement carries the relevant scope/policy predicate.
|
|
219
|
+
|
|
220
|
+
These batches start with a write and keep result reads in that same primary
|
|
221
|
+
transaction. Do not replace them with replica observations, separate
|
|
222
|
+
`SELECT`/mutation calls, or `db.transaction`. The adapter never uses a session
|
|
223
|
+
bookmark as a lock. Initial window calculation explicitly casts the bound
|
|
224
|
+
period to INTEGER because D1 binds JS numbers as REAL; otherwise integer
|
|
225
|
+
window alignment is lost.
|
|
226
|
+
|
|
227
|
+
Database time is sampled by the batch's first bucket mutation and reused via
|
|
228
|
+
`last_now` for the rest of the operation. Persistent high-water semantics match
|
|
229
|
+
the other modes. Leases use separate indexed rows so SQL can prune and count
|
|
230
|
+
them atomically; explaining weighted retry times reads the holders within the
|
|
231
|
+
same batch. Intended for moderate concurrency; D1 query/response limits and
|
|
232
|
+
network faults remain backend errors.
|
|
233
|
+
|
|
234
|
+
Tests execute local **workerd's native D1 binding**, not a hand-written SQLite
|
|
235
|
+
double: concurrent batches, whole-batch rollback, expiry/old tokens, failure
|
|
236
|
+
policies and real `createWorkerHandler` + `createD1Plugin` request assembly.
|
|
237
|
+
Requests can share the same D1 database independently of per-request app
|
|
238
|
+
instances. Cloudflare-hosted D1 network, replication/failover and geographic
|
|
239
|
+
behavior are **not validated by local workerd tests**.
|
|
240
|
+
|
|
241
|
+
## Leases and execution lifetime
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
await limits.withLease(
|
|
245
|
+
{ scope, capacity: 4, quantity: 1, ttlMs: 30_000 },
|
|
246
|
+
async ({ signal, lease }) => {
|
|
247
|
+
// Use the same application-owned Auth/Tasks service and honor signal.
|
|
248
|
+
await doWork({ signal });
|
|
249
|
+
},
|
|
250
|
+
{ signal: requestSignal, renewEveryMs: 10_000 },
|
|
251
|
+
);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Manual `acquire` returns `lease` with an unguessable UUID token, scope, weight and
|
|
255
|
+
epoch-millisecond `expiresAt`. Use `renew(lease, ttlMs)` and `release(lease)`.
|
|
256
|
+
Renewal never shortens a valid lease; expired/missing tokens return `null`.
|
|
257
|
+
Duplicate release is successful. Only the matching token is changed, so a stale
|
|
258
|
+
holder cannot renew or release a replacement. Tokens are capabilities, not
|
|
259
|
+
monotonic fencing numbers; do not publish them to untrusted clients or logs.
|
|
260
|
+
Raw `LimitStore.acquire(input, token)` is a trusted provider boundary, not the
|
|
261
|
+
application lease API: its caller must supply a fresh, never-reused token.
|
|
262
|
+
Applications use `createLimits(...).acquire` to get that behavior.
|
|
263
|
+
|
|
264
|
+
Manual leases have **no automatic renewal**. In `withLease`, renewal starts only
|
|
265
|
+
with an explicit `renewEveryMs` integer below TTL (and at most 2147483647 ms).
|
|
266
|
+
The wrapper owns its timer, prevents overlapping renewals, aborts on lost/failed
|
|
267
|
+
renewal, awaits in-flight renewal, and releases in `finally`. Body and release
|
|
268
|
+
errors both survive. Long stalls can still miss expiry.
|
|
269
|
+
|
|
270
|
+
Request cancellation signals the callback cooperatively and stops renewal;
|
|
271
|
+
`finally` releases after the callback settles. `close()` stops new admission,
|
|
272
|
+
aborts and drains owned wrappers and pending store calls, then releases manual
|
|
273
|
+
leases. A callback ignoring cancellation can delay close indefinitely: the
|
|
274
|
+
library does not pretend it has stopped execution. Do not await `limits.close()`
|
|
275
|
+
from inside its own running callback. The host must drain business work using
|
|
276
|
+
manual leases before closing their owning service/DB.
|
|
277
|
+
|
|
278
|
+
**TTL recovers valid lease weight, not necessarily running work.** An expired
|
|
279
|
+
task may still execute while a replacement runs. No strict execution bound,
|
|
280
|
+
fencing, revocation acknowledgement or exactly-once guarantee is claimed.
|
|
281
|
+
Stronger guarantees require the protected execution/effect endpoint to enforce
|
|
282
|
+
fencing or confirm cancellation itself.
|
|
283
|
+
|
|
284
|
+
## Identity, faults and optional exposure
|
|
285
|
+
|
|
286
|
+
These services are trusted internal APIs, not public authorization endpoints.
|
|
287
|
+
Obtain an actor using the application's existing Auth audience, call its
|
|
288
|
+
`access.enforce` policy, derive tenant/subject from verified identity/membership,
|
|
289
|
+
and only then construct scope and consume. Do not copy `tenant`, `instance`,
|
|
290
|
+
`key`, `capacity` or `quantity` from arbitrary request JSON. An application may
|
|
291
|
+
derive a business key from an authorized resource, but a client-selected key must
|
|
292
|
+
not partition away that subject's limit.
|
|
293
|
+
|
|
294
|
+
`failurePolicy` has no default:
|
|
295
|
+
|
|
296
|
+
| Policy | Backend failure on consume/acquire |
|
|
297
|
+
| ------- | --------------------------------------------------------------------------------- |
|
|
298
|
+
| `throw` | Reject with `LimitError("backend-failure")`, retaining the cause in-process |
|
|
299
|
+
| `deny` | Denied, `reason: "backend-failure"`, unknown remaining/retry |
|
|
300
|
+
| `allow` | Allowed, visibly degraded, unknown remaining/retry; acquire returns `lease: null` |
|
|
301
|
+
|
|
302
|
+
Choose `allow` only when executing without a confirmed quota/lease is acceptable.
|
|
303
|
+
It also applies to `withLease`, whose callback then receives `lease: null`.
|
|
304
|
+
An unknown write outcome may already have consumed quota or created a lease:
|
|
305
|
+
there is **no automatic retry, refund or fabricated token**. Unknown leases
|
|
306
|
+
recover via TTL. Renewal and release failures always reject regardless of policy;
|
|
307
|
+
there is no meaningful fail-open renewal or confirmation of an unknown release.
|
|
308
|
+
Shutdown retries still-owned releases after a wrapper failure and aggregates
|
|
309
|
+
remaining cleanup failures.
|
|
310
|
+
|
|
311
|
+
The plugin reuses the existing contextual logger for a fixed backend-failure
|
|
312
|
+
warning without scope, token or backend error text. It does not bootstrap Log,
|
|
313
|
+
OTel, workers or a provider. Existing application telemetry can wrap calls.
|
|
314
|
+
|
|
315
|
+
No Web, CLI, MCP or Manage operation is exposed automatically, and no Manage
|
|
316
|
+
adapter is shipped in this first version. If needed, application-owned adapters
|
|
317
|
+
should declare only an explicit Operation/Manage subset over an already
|
|
318
|
+
authorized business service, with trusted identity binding and tenant/object
|
|
319
|
+
policy; never expose the raw scope-taking service as an unauthenticated tool.
|
|
320
|
+
Do not treat a Manage declaration or CLI input as an identity or permission.
|
|
321
|
+
|
|
322
|
+
## Evidence and current limits
|
|
323
|
+
|
|
324
|
+
Focused tests cover memory boundaries, clock rollback, scope/namespace isolation,
|
|
325
|
+
quantity overflow, policy conflicts, stale/duplicate/expired tokens, wrapper
|
|
326
|
+
renewal/cancellation/shutdown, failure policies, real SQLite writer contention,
|
|
327
|
+
and existing public Config/Auth/DB lifecycle integration. A gated four-process
|
|
328
|
+
file-backed SQLite test proves shared admission rather than same-loop mocks.
|
|
329
|
+
Auth credentials in that test are local fixtures, not a production provider.
|
|
330
|
+
PostgreSQL tests use owned local clusters; D1/Workers tests use local Miniflare
|
|
331
|
+
and workerd with a disposable native D1 binding. No production connection,
|
|
332
|
+
Cloudflare credential or deployment is needed.
|
|
333
|
+
|
|
334
|
+
Bucket policy rows are retained, including empty buckets, to preserve policy
|
|
335
|
+
and clock history. Scope cardinality and DB maintenance are application-owned;
|
|
336
|
+
there is no built-in sweep/reset/admin permission. Deleting a bucket explicitly
|
|
337
|
+
resets its allowance/history and must not be exposed to untrusted callers.
|
|
338
|
+
|
|
339
|
+
Backend semantics checked against current official docs:
|
|
340
|
+
[Bun transactions](https://bun.sh/docs/api/sqlite#transactions),
|
|
341
|
+
[SQLite transactions](https://sqlite.org/lang_transaction.html),
|
|
342
|
+
[SQLite time](https://sqlite.org/lang_datefunc.html),
|
|
343
|
+
[PG row locks](https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS),
|
|
344
|
+
[PG time](https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-CURRENT),
|
|
345
|
+
[D1 batch and sessions](https://developers.cloudflare.com/d1/worker-api/d1-database/).
|
|
346
|
+
|
|
347
|
+
Development checks: build `@lenso/core`, `@lenso/db`, `@lenso/auth` and
|
|
348
|
+
`@lenso/workers` before this package, then run its `build`, `typecheck` and `test`
|
|
349
|
+
scripts. Set `LENSO_REQUIRE_POSTGRES=1` to require the local PG binaries rather
|
|
350
|
+
than skip PG tests. Miniflare/workerd are test-only dependencies; no provider
|
|
351
|
+
credentials are read. Install the registered workspace dependencies from the
|
|
352
|
+
single root Bun lockfile with `bun install --frozen-lockfile`; dependency changes
|
|
353
|
+
and lockfile updates remain the integration owner's responsibility.
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import { type LimitConfig } from "./contracts";
|
|
3
|
+
export declare function validateConfig(value: unknown): LimitConfig;
|
|
4
|
+
export declare const limitConfigSchema: StandardSchemaV1<LimitConfig, LimitConfig>;
|
|
5
|
+
export declare const limitConfig: import("@lenso/core/config").ConfigContract<StandardSchemaV1<LimitConfig, LimitConfig>>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** Trusted application scope, never a client-selected identity or authorization grant. */
|
|
2
|
+
export interface LimitScope {
|
|
3
|
+
readonly instance: string;
|
|
4
|
+
readonly tenant: string;
|
|
5
|
+
readonly key: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ConsumeInput {
|
|
8
|
+
readonly scope: LimitScope;
|
|
9
|
+
readonly capacity: number;
|
|
10
|
+
readonly quantity: number;
|
|
11
|
+
readonly periodMs: number;
|
|
12
|
+
}
|
|
13
|
+
export interface AcquireInput {
|
|
14
|
+
readonly scope: LimitScope;
|
|
15
|
+
readonly capacity: number;
|
|
16
|
+
readonly quantity: number;
|
|
17
|
+
readonly ttlMs: number;
|
|
18
|
+
}
|
|
19
|
+
export type CounterKind = "rate" | "quota";
|
|
20
|
+
export type FailurePolicy = "throw" | "deny" | "allow";
|
|
21
|
+
export interface LimitConfig {
|
|
22
|
+
readonly failurePolicy: FailurePolicy;
|
|
23
|
+
}
|
|
24
|
+
export interface Decision {
|
|
25
|
+
readonly allowed: boolean;
|
|
26
|
+
/** Null when the backend outcome is unknown. */
|
|
27
|
+
readonly remaining: number | null;
|
|
28
|
+
/** Milliseconds until this quantity can fit; null means no finite estimate. */
|
|
29
|
+
readonly retryAfter: number | null;
|
|
30
|
+
readonly reason: "allowed" | "exhausted" | "too-large" | "backend-failure";
|
|
31
|
+
}
|
|
32
|
+
export interface Lease {
|
|
33
|
+
readonly scope: LimitScope;
|
|
34
|
+
readonly token: string;
|
|
35
|
+
readonly quantity: number;
|
|
36
|
+
readonly expiresAt: number;
|
|
37
|
+
}
|
|
38
|
+
export interface Acquisition extends Decision {
|
|
39
|
+
/** Fail-open never fabricates a lease. */
|
|
40
|
+
readonly lease: Lease | null;
|
|
41
|
+
}
|
|
42
|
+
/** Each method must be atomic for its scope; this interface is not a transaction emulator. */
|
|
43
|
+
export interface LimitStore {
|
|
44
|
+
consume(kind: CounterKind, input: ConsumeInput): Promise<Decision>;
|
|
45
|
+
/** Trusted provider boundary: the service supplies a fresh UUID; callers must never reuse tokens. */
|
|
46
|
+
acquire(input: AcquireInput, token: string): Promise<Acquisition>;
|
|
47
|
+
renew(scope: LimitScope, token: string, ttlMs: number): Promise<Lease | null>;
|
|
48
|
+
release(scope: LimitScope, token: string): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
export declare class LimitError extends Error {
|
|
51
|
+
readonly code: "invalid-input" | "policy-conflict" | "backend-failure" | "closed" | "denied" | "lease-lost";
|
|
52
|
+
constructor(code: "invalid-input" | "policy-conflict" | "backend-failure" | "closed" | "denied" | "lease-lost", options?: ErrorOptions);
|
|
53
|
+
}
|
|
54
|
+
export declare const maxDurationMs = 31622400000;
|
|
55
|
+
export declare function positiveInteger(value: number, maximum?: number): void;
|
|
56
|
+
export declare function scopeKey(scope: LimitScope): string;
|
|
57
|
+
export declare function validateConsume(input: ConsumeInput): void;
|
|
58
|
+
export declare function validateAcquire(input: AcquireInput): void;
|
|
59
|
+
export declare function validateToken(token: string): void;
|
|
60
|
+
export declare function validateKind(kind: CounterKind): void;
|