@lenso/limits 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/dist/d1.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LimitError2,
|
|
3
|
+
maxDurationMs,
|
|
4
|
+
positiveInteger,
|
|
5
|
+
scopeKey,
|
|
6
|
+
validateConsume,
|
|
7
|
+
validateAcquire,
|
|
8
|
+
validateToken,
|
|
9
|
+
validateKind,
|
|
10
|
+
counterDecision,
|
|
11
|
+
leaseDecision
|
|
12
|
+
} from "./index-bz4qnpx8.js";
|
|
13
|
+
|
|
14
|
+
// src/d1.ts
|
|
15
|
+
import { and, eq, lte, sql } from "drizzle-orm";
|
|
16
|
+
import { check, index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
17
|
+
var d1Counters = sqliteTable("lenso_d1_limit_counters", {
|
|
18
|
+
scope: text("scope").notNull(),
|
|
19
|
+
kind: text("kind").$type().notNull(),
|
|
20
|
+
capacity: integer("capacity").notNull(),
|
|
21
|
+
periodMs: integer("period_ms").notNull(),
|
|
22
|
+
windowStart: integer("window_start").notNull(),
|
|
23
|
+
used: integer("used").notNull(),
|
|
24
|
+
lastNow: integer("last_now").notNull()
|
|
25
|
+
}, (table) => [
|
|
26
|
+
primaryKey({ columns: [table.scope, table.kind] }),
|
|
27
|
+
check("lenso_d1_counter_kind", sql`${table.kind} IN ('rate', 'quota')`),
|
|
28
|
+
check("lenso_d1_counter_capacity", sql`${table.capacity} BETWEEN 1 AND 2147483647`),
|
|
29
|
+
check("lenso_d1_counter_period", sql`${table.periodMs} BETWEEN 1 AND 31622400000`),
|
|
30
|
+
check("lenso_d1_counter_used", sql`${table.used} BETWEEN 0 AND ${table.capacity}`),
|
|
31
|
+
check("lenso_d1_counter_clock", sql`${table.windowStart} >= 0 AND ${table.lastNow} >= 0`)
|
|
32
|
+
]);
|
|
33
|
+
var d1Concurrency = sqliteTable("lenso_d1_limit_concurrency", {
|
|
34
|
+
scope: text("scope").primaryKey().notNull(),
|
|
35
|
+
capacity: integer("capacity").notNull(),
|
|
36
|
+
lastNow: integer("last_now").notNull()
|
|
37
|
+
}, (table) => [
|
|
38
|
+
check("lenso_d1_lease_capacity", sql`${table.capacity} BETWEEN 1 AND 2147483647`),
|
|
39
|
+
check("lenso_d1_lease_clock", sql`${table.lastNow} >= 0`)
|
|
40
|
+
]);
|
|
41
|
+
var d1Leases = sqliteTable("lenso_d1_limit_leases", {
|
|
42
|
+
scope: text("scope").notNull().references(() => d1Concurrency.scope),
|
|
43
|
+
token: text("token").notNull(),
|
|
44
|
+
quantity: integer("quantity").notNull(),
|
|
45
|
+
expiresAt: integer("expires_at").notNull()
|
|
46
|
+
}, (table) => [
|
|
47
|
+
primaryKey({ columns: [table.scope, table.token] }),
|
|
48
|
+
index("lenso_d1_lease_expiry").on(table.scope, table.expiresAt),
|
|
49
|
+
check("lenso_d1_lease_quantity", sql`${table.quantity} BETWEEN 1 AND 2147483647`),
|
|
50
|
+
check("lenso_d1_lease_expiry_check", sql`${table.expiresAt} >= 0`)
|
|
51
|
+
]);
|
|
52
|
+
var d1LimitSchema = { d1Counters, d1Concurrency, d1Leases };
|
|
53
|
+
var databaseNow = sql`CAST(round((julianday('now') - 2440587.5) * 86400000) AS INTEGER)`;
|
|
54
|
+
function presentLease(scope, row) {
|
|
55
|
+
return Object.freeze({ ...row, scope: Object.freeze({ ...scope }) });
|
|
56
|
+
}
|
|
57
|
+
function createD1LimitStore(db) {
|
|
58
|
+
const bucketTime = (key, capacity) => sql`(
|
|
59
|
+
SELECT ${d1Concurrency.lastNow} FROM ${d1Concurrency}
|
|
60
|
+
WHERE ${and(eq(d1Concurrency.scope, key), capacity === undefined ? undefined : eq(d1Concurrency.capacity, capacity))}
|
|
61
|
+
)`;
|
|
62
|
+
const prune = (key, capacity) => db.delete(d1Leases).where(and(eq(d1Leases.scope, key), lte(d1Leases.expiresAt, bucketTime(key, capacity))));
|
|
63
|
+
const advanceClock = (key) => db.update(d1Concurrency).set({ lastNow: sql`max(${d1Concurrency.lastNow}, ${databaseNow})` }).where(eq(d1Concurrency.scope, key));
|
|
64
|
+
return {
|
|
65
|
+
async consume(kind, input) {
|
|
66
|
+
validateKind(kind);
|
|
67
|
+
validateConsume(input);
|
|
68
|
+
const key = scopeKey(input.scope);
|
|
69
|
+
const samePolicy = and(eq(d1Counters.capacity, input.capacity), eq(d1Counters.periodMs, input.periodMs));
|
|
70
|
+
const scopePredicate = and(eq(d1Counters.scope, key), eq(d1Counters.kind, kind));
|
|
71
|
+
const effectiveNow = sql`max(${d1Counters.lastNow}, ${databaseNow})`;
|
|
72
|
+
const nextWindow = sql`(${effectiveNow} / ${d1Counters.periodMs}) * ${d1Counters.periodMs}`;
|
|
73
|
+
const [, admitted, snapshot] = await db.batch([
|
|
74
|
+
db.insert(d1Counters).values({
|
|
75
|
+
scope: key,
|
|
76
|
+
kind,
|
|
77
|
+
capacity: input.capacity,
|
|
78
|
+
periodMs: input.periodMs,
|
|
79
|
+
windowStart: sql`(${databaseNow} / CAST(${input.periodMs} AS INTEGER)) * CAST(${input.periodMs} AS INTEGER)`,
|
|
80
|
+
used: 0,
|
|
81
|
+
lastNow: databaseNow
|
|
82
|
+
}).onConflictDoUpdate({
|
|
83
|
+
target: [d1Counters.scope, d1Counters.kind],
|
|
84
|
+
set: {
|
|
85
|
+
lastNow: effectiveNow,
|
|
86
|
+
windowStart: nextWindow,
|
|
87
|
+
used: sql`CASE WHEN ${d1Counters.windowStart} = ${nextWindow} THEN ${d1Counters.used} ELSE 0 END`
|
|
88
|
+
},
|
|
89
|
+
setWhere: samePolicy
|
|
90
|
+
}),
|
|
91
|
+
db.update(d1Counters).set({ used: sql`${d1Counters.used} + ${input.quantity}` }).where(and(scopePredicate, samePolicy, sql`${input.quantity} <= ${d1Counters.capacity} - ${d1Counters.used}`)).returning(),
|
|
92
|
+
db.select().from(d1Counters).where(scopePredicate)
|
|
93
|
+
]);
|
|
94
|
+
const state = snapshot[0];
|
|
95
|
+
if (!state)
|
|
96
|
+
throw new LimitError2("backend-failure");
|
|
97
|
+
if (state.capacity !== input.capacity || state.periodMs !== input.periodMs)
|
|
98
|
+
throw new LimitError2("policy-conflict");
|
|
99
|
+
return counterDecision(state, input.quantity, admitted.length === 1);
|
|
100
|
+
},
|
|
101
|
+
async acquire(input, token) {
|
|
102
|
+
validateAcquire(input);
|
|
103
|
+
validateToken(token);
|
|
104
|
+
const key = scopeKey(input.scope);
|
|
105
|
+
const used = sql`(
|
|
106
|
+
SELECT coalesce(sum(${d1Leases.quantity}), 0) FROM ${d1Leases}
|
|
107
|
+
WHERE ${d1Leases.scope} = ${key}
|
|
108
|
+
)`;
|
|
109
|
+
const [, , admitted, headers, holders] = await db.batch([
|
|
110
|
+
db.insert(d1Concurrency).values({ scope: key, capacity: input.capacity, lastNow: databaseNow }).onConflictDoUpdate({
|
|
111
|
+
target: d1Concurrency.scope,
|
|
112
|
+
set: { lastNow: sql`max(${d1Concurrency.lastNow}, ${databaseNow})` },
|
|
113
|
+
setWhere: eq(d1Concurrency.capacity, input.capacity)
|
|
114
|
+
}),
|
|
115
|
+
prune(key, input.capacity),
|
|
116
|
+
db.insert(d1Leases).select(db.select({
|
|
117
|
+
scope: sql`${key}`.as("scope"),
|
|
118
|
+
token: sql`${token}`.as("token"),
|
|
119
|
+
quantity: sql`${input.quantity}`.as("quantity"),
|
|
120
|
+
expiresAt: sql`${d1Concurrency.lastNow} + ${input.ttlMs}`.as("expires_at")
|
|
121
|
+
}).from(d1Concurrency).where(and(eq(d1Concurrency.scope, key), eq(d1Concurrency.capacity, input.capacity), sql`${input.quantity} <= ${d1Concurrency.capacity} - ${used}`))).returning(),
|
|
122
|
+
db.select().from(d1Concurrency).where(eq(d1Concurrency.scope, key)),
|
|
123
|
+
db.select().from(d1Leases).where(eq(d1Leases.scope, key))
|
|
124
|
+
]);
|
|
125
|
+
const header = headers[0];
|
|
126
|
+
if (!header)
|
|
127
|
+
throw new LimitError2("backend-failure");
|
|
128
|
+
if (header.capacity !== input.capacity)
|
|
129
|
+
throw new LimitError2("policy-conflict");
|
|
130
|
+
const leases = holders.map((row) => presentLease(input.scope, row));
|
|
131
|
+
return {
|
|
132
|
+
...leaseDecision({ ...header, leases }, input.quantity, admitted.length === 1),
|
|
133
|
+
lease: admitted[0] ? presentLease(input.scope, admitted[0]) : null
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
async renew(scope, token, ttlMs) {
|
|
137
|
+
const key = scopeKey(scope);
|
|
138
|
+
validateToken(token);
|
|
139
|
+
positiveInteger(ttlMs, maxDurationMs);
|
|
140
|
+
const [, , rows] = await db.batch([
|
|
141
|
+
advanceClock(key),
|
|
142
|
+
prune(key),
|
|
143
|
+
db.update(d1Leases).set({ expiresAt: sql`max(${d1Leases.expiresAt}, ${bucketTime(key)} + ${ttlMs})` }).where(and(eq(d1Leases.scope, key), eq(d1Leases.token, token))).returning()
|
|
144
|
+
]);
|
|
145
|
+
return rows[0] ? presentLease(scope, rows[0]) : null;
|
|
146
|
+
},
|
|
147
|
+
async release(scope, token) {
|
|
148
|
+
const key = scopeKey(scope);
|
|
149
|
+
validateToken(token);
|
|
150
|
+
await db.batch([
|
|
151
|
+
advanceClock(key),
|
|
152
|
+
prune(key),
|
|
153
|
+
db.delete(d1Leases).where(and(eq(d1Leases.scope, key), eq(d1Leases.token, token)))
|
|
154
|
+
]);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export {
|
|
159
|
+
createD1LimitStore,
|
|
160
|
+
d1Concurrency,
|
|
161
|
+
d1Counters,
|
|
162
|
+
d1Leases,
|
|
163
|
+
d1LimitSchema
|
|
164
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// src/contracts.ts
|
|
2
|
+
class LimitError2 extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, options) {
|
|
5
|
+
super(`Limit operation failed: ${code}`, options);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "LimitError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
var maxDurationMs = 31622400000;
|
|
11
|
+
function positiveInteger(value, maximum = 2147483647) {
|
|
12
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum)
|
|
13
|
+
throw new LimitError2("invalid-input");
|
|
14
|
+
}
|
|
15
|
+
function scopeKey(scope) {
|
|
16
|
+
if (!scope || typeof scope !== "object")
|
|
17
|
+
throw new LimitError2("invalid-input");
|
|
18
|
+
const parts = [scope.instance, scope.tenant, scope.key];
|
|
19
|
+
if (parts.some((part) => typeof part !== "string" || part.length < 1 || part.length > 512))
|
|
20
|
+
throw new LimitError2("invalid-input");
|
|
21
|
+
return JSON.stringify(parts);
|
|
22
|
+
}
|
|
23
|
+
function validateConsume(input) {
|
|
24
|
+
if (!input)
|
|
25
|
+
throw new LimitError2("invalid-input");
|
|
26
|
+
scopeKey(input.scope);
|
|
27
|
+
positiveInteger(input.capacity);
|
|
28
|
+
positiveInteger(input.quantity);
|
|
29
|
+
positiveInteger(input.periodMs, maxDurationMs);
|
|
30
|
+
}
|
|
31
|
+
function validateAcquire(input) {
|
|
32
|
+
if (!input)
|
|
33
|
+
throw new LimitError2("invalid-input");
|
|
34
|
+
scopeKey(input.scope);
|
|
35
|
+
positiveInteger(input.capacity);
|
|
36
|
+
positiveInteger(input.quantity);
|
|
37
|
+
positiveInteger(input.ttlMs, maxDurationMs);
|
|
38
|
+
}
|
|
39
|
+
function validateToken(token) {
|
|
40
|
+
if (typeof token !== "string" || token.length < 1 || token.length > 128)
|
|
41
|
+
throw new LimitError2("invalid-input");
|
|
42
|
+
}
|
|
43
|
+
function validateKind(kind) {
|
|
44
|
+
if (kind !== "rate" && kind !== "quota")
|
|
45
|
+
throw new LimitError2("invalid-input");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// src/state.ts
|
|
49
|
+
function clockTime(now, previous = 0) {
|
|
50
|
+
if (!Number.isSafeInteger(now) || now < 0 || now > Number.MAX_SAFE_INTEGER - maxDurationMs * 2)
|
|
51
|
+
throw new LimitError2("invalid-input");
|
|
52
|
+
return Math.max(now, previous);
|
|
53
|
+
}
|
|
54
|
+
function consumeState(previous, input, clock) {
|
|
55
|
+
if (previous && (previous.capacity !== input.capacity || previous.periodMs !== input.periodMs))
|
|
56
|
+
throw new LimitError2("policy-conflict");
|
|
57
|
+
const now = clockTime(clock, previous?.lastNow);
|
|
58
|
+
const windowStart = Math.floor(now / input.periodMs) * input.periodMs;
|
|
59
|
+
const state = {
|
|
60
|
+
capacity: input.capacity,
|
|
61
|
+
periodMs: input.periodMs,
|
|
62
|
+
windowStart,
|
|
63
|
+
used: previous?.windowStart === windowStart ? previous.used : 0,
|
|
64
|
+
lastNow: now
|
|
65
|
+
};
|
|
66
|
+
const allowed = input.quantity <= state.capacity - state.used;
|
|
67
|
+
if (allowed)
|
|
68
|
+
state.used += input.quantity;
|
|
69
|
+
return { state, result: counterDecision(state, input.quantity, allowed) };
|
|
70
|
+
}
|
|
71
|
+
function counterDecision(state, quantity, allowed) {
|
|
72
|
+
return {
|
|
73
|
+
allowed,
|
|
74
|
+
remaining: state.capacity - state.used,
|
|
75
|
+
retryAfter: allowed ? 0 : quantity > state.capacity ? null : state.windowStart + state.periodMs - state.lastNow,
|
|
76
|
+
reason: allowed ? "allowed" : quantity > state.capacity ? "too-large" : "exhausted"
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function liveState(previous, capacity, clock) {
|
|
80
|
+
if (previous && previous.capacity !== capacity)
|
|
81
|
+
throw new LimitError2("policy-conflict");
|
|
82
|
+
const now = clockTime(clock, previous?.lastNow);
|
|
83
|
+
return {
|
|
84
|
+
capacity,
|
|
85
|
+
lastNow: now,
|
|
86
|
+
leases: previous?.leases.filter((lease) => lease.expiresAt > now) ?? []
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function acquireState(state, input, token) {
|
|
90
|
+
const used = state.leases.reduce((sum, lease) => sum + lease.quantity, 0);
|
|
91
|
+
const allowed = input.quantity <= state.capacity - used;
|
|
92
|
+
if (state.leases.some((lease) => lease.token === token))
|
|
93
|
+
throw new LimitError2("invalid-input");
|
|
94
|
+
const lease = allowed ? Object.freeze({
|
|
95
|
+
scope: Object.freeze({ ...input.scope }),
|
|
96
|
+
token,
|
|
97
|
+
quantity: input.quantity,
|
|
98
|
+
expiresAt: state.lastNow + input.ttlMs
|
|
99
|
+
}) : null;
|
|
100
|
+
if (lease)
|
|
101
|
+
state.leases.push(lease);
|
|
102
|
+
return { ...leaseDecision(state, input.quantity, allowed), lease };
|
|
103
|
+
}
|
|
104
|
+
function leaseDecision(state, quantity, allowed) {
|
|
105
|
+
const used = state.leases.reduce((sum, lease) => sum + lease.quantity, 0);
|
|
106
|
+
let retryAfter = allowed ? 0 : null;
|
|
107
|
+
if (!allowed && quantity <= state.capacity) {
|
|
108
|
+
let available = state.capacity - used;
|
|
109
|
+
for (const item of [...state.leases].sort((a, b) => a.expiresAt - b.expiresAt)) {
|
|
110
|
+
available += item.quantity;
|
|
111
|
+
if (available >= quantity) {
|
|
112
|
+
retryAfter = item.expiresAt - state.lastNow;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
allowed,
|
|
119
|
+
remaining: state.capacity - used,
|
|
120
|
+
retryAfter,
|
|
121
|
+
reason: allowed ? "allowed" : quantity > state.capacity ? "too-large" : "exhausted"
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function renewState(state, scope, token, ttlMs) {
|
|
125
|
+
const index = state.leases.findIndex((lease) => lease.token === token);
|
|
126
|
+
if (index === -1)
|
|
127
|
+
return null;
|
|
128
|
+
const previous = state.leases[index];
|
|
129
|
+
const renewed = Object.freeze({
|
|
130
|
+
...previous,
|
|
131
|
+
scope: Object.freeze({ ...scope }),
|
|
132
|
+
expiresAt: Math.max(previous.expiresAt, state.lastNow + ttlMs)
|
|
133
|
+
});
|
|
134
|
+
state.leases[index] = renewed;
|
|
135
|
+
return renewed;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export { LimitError2, maxDurationMs, positiveInteger, scopeKey, validateConsume, validateAcquire, validateToken, validateKind, consumeState, counterDecision, liveState, acquireState, leaseDecision, renewState };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ConfigSource } from "@lenso/core/config";
|
|
2
|
+
import type { Plugin, PluginContext } from "@lenso/core/plugin";
|
|
3
|
+
import type { LimitConfig, LimitStore } from "./contracts";
|
|
4
|
+
import { type Limits } from "./service";
|
|
5
|
+
export type * from "./contracts";
|
|
6
|
+
export { LimitError } from "./contracts";
|
|
7
|
+
export { limitConfig, limitConfigSchema } from "./config";
|
|
8
|
+
export { createMemoryLimitStore } from "./memory";
|
|
9
|
+
export { createLimits } from "./service";
|
|
10
|
+
export type { Limits, LeaseExecution, LeaseRunOptions } from "./service";
|
|
11
|
+
/** The caller supplies exact dependencies; connect borrows a store and acquires no global context. */
|
|
12
|
+
export declare function createLimitsPlugin(options: {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly requires?: readonly Plugin<unknown>[];
|
|
15
|
+
readonly config: LimitConfig | readonly ConfigSource[];
|
|
16
|
+
readonly connect: (context: PluginContext) => LimitStore | Promise<LimitStore>;
|
|
17
|
+
}): Plugin<Limits>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LimitError2,
|
|
3
|
+
maxDurationMs,
|
|
4
|
+
positiveInteger,
|
|
5
|
+
scopeKey,
|
|
6
|
+
validateConsume,
|
|
7
|
+
validateAcquire,
|
|
8
|
+
validateToken,
|
|
9
|
+
validateKind,
|
|
10
|
+
consumeState,
|
|
11
|
+
liveState,
|
|
12
|
+
acquireState,
|
|
13
|
+
renewState
|
|
14
|
+
} from "./index-bz4qnpx8.js";
|
|
15
|
+
|
|
16
|
+
// src/index.ts
|
|
17
|
+
import { bindConfig } from "@lenso/core/config";
|
|
18
|
+
|
|
19
|
+
// src/config.ts
|
|
20
|
+
import { definePluginConfig } from "@lenso/core/config";
|
|
21
|
+
function validateConfig(value) {
|
|
22
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => key !== "failurePolicy") || !("failurePolicy" in value) || !["throw", "deny", "allow"].includes(value.failurePolicy))
|
|
23
|
+
throw new LimitError2("invalid-input");
|
|
24
|
+
return { failurePolicy: value.failurePolicy };
|
|
25
|
+
}
|
|
26
|
+
var limitConfigSchema = {
|
|
27
|
+
"~standard": {
|
|
28
|
+
version: 1,
|
|
29
|
+
vendor: "@lenso/limits",
|
|
30
|
+
validate(value) {
|
|
31
|
+
try {
|
|
32
|
+
return { value: validateConfig(value) };
|
|
33
|
+
} catch {
|
|
34
|
+
return { issues: [{ message: "Select an explicit limit backend failure policy." }] };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var limitConfig = definePluginConfig({
|
|
40
|
+
schema: limitConfigSchema,
|
|
41
|
+
description: "Explicit admission behavior on limit backend failure",
|
|
42
|
+
fields: [{ path: ["failurePolicy"], description: "throw, deny, or visibly degraded allow" }],
|
|
43
|
+
jsonSchema: () => ({
|
|
44
|
+
type: "object",
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
required: ["failurePolicy"],
|
|
47
|
+
properties: { failurePolicy: { type: "string", enum: ["throw", "deny", "allow"] } }
|
|
48
|
+
})
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// src/service.ts
|
|
52
|
+
function createLimits(options) {
|
|
53
|
+
const config = validateConfig(options.config);
|
|
54
|
+
const owned = new Map;
|
|
55
|
+
const pending = new Set;
|
|
56
|
+
const runs = new Map;
|
|
57
|
+
let closed = false;
|
|
58
|
+
let closing;
|
|
59
|
+
function operation(work) {
|
|
60
|
+
if (closed)
|
|
61
|
+
return Promise.reject(new LimitError2("closed"));
|
|
62
|
+
const promise = Promise.resolve().then(work);
|
|
63
|
+
pending.add(promise);
|
|
64
|
+
promise.then(() => pending.delete(promise), () => pending.delete(promise));
|
|
65
|
+
return promise;
|
|
66
|
+
}
|
|
67
|
+
function failure(error) {
|
|
68
|
+
if (error instanceof LimitError2 && error.code !== "backend-failure")
|
|
69
|
+
throw error;
|
|
70
|
+
try {
|
|
71
|
+
options.logger?.warn({ event: "limits.backend-failure", policy: config.failurePolicy }, "Limit backend failed");
|
|
72
|
+
} catch {}
|
|
73
|
+
if (config.failurePolicy === "throw")
|
|
74
|
+
throw new LimitError2("backend-failure", { cause: error });
|
|
75
|
+
return {
|
|
76
|
+
allowed: config.failurePolicy === "allow",
|
|
77
|
+
remaining: null,
|
|
78
|
+
retryAfter: null,
|
|
79
|
+
reason: "backend-failure"
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function consume(kind, input) {
|
|
83
|
+
validateConsume(input);
|
|
84
|
+
const snapshot = { ...input, scope: { ...input.scope } };
|
|
85
|
+
return operation(async () => {
|
|
86
|
+
try {
|
|
87
|
+
return await options.store.consume(kind, snapshot);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return failure(error);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
async function releaseOwned(lease) {
|
|
94
|
+
await options.store.release(lease.scope, lease.token);
|
|
95
|
+
owned.delete(lease.token);
|
|
96
|
+
}
|
|
97
|
+
function validateOwnedScope(lease) {
|
|
98
|
+
if (!lease || typeof lease !== "object")
|
|
99
|
+
throw new LimitError2("invalid-input");
|
|
100
|
+
const key = scopeKey(lease.scope);
|
|
101
|
+
const previous = owned.get(lease.token);
|
|
102
|
+
if (previous && scopeKey(previous.scope) !== key)
|
|
103
|
+
throw new LimitError2("invalid-input");
|
|
104
|
+
}
|
|
105
|
+
const service = {
|
|
106
|
+
consumeRate: (input) => consume("rate", input),
|
|
107
|
+
consumeQuota: (input) => consume("quota", input),
|
|
108
|
+
acquire(input) {
|
|
109
|
+
validateAcquire(input);
|
|
110
|
+
const snapshot = { ...input, scope: { ...input.scope } };
|
|
111
|
+
return operation(async () => {
|
|
112
|
+
try {
|
|
113
|
+
const result = await options.store.acquire(snapshot, crypto.randomUUID());
|
|
114
|
+
if (result.lease)
|
|
115
|
+
owned.set(result.lease.token, result.lease);
|
|
116
|
+
return result;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return { ...failure(error), lease: null };
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
renew(lease, ttlMs) {
|
|
123
|
+
validateOwnedScope(lease);
|
|
124
|
+
const scope = { ...lease.scope };
|
|
125
|
+
scopeKey(scope);
|
|
126
|
+
validateToken(lease.token);
|
|
127
|
+
positiveInteger(ttlMs, maxDurationMs);
|
|
128
|
+
const token = lease.token;
|
|
129
|
+
return operation(async () => {
|
|
130
|
+
const renewed = await options.store.renew(scope, token, ttlMs);
|
|
131
|
+
if (owned.has(token)) {
|
|
132
|
+
if (renewed)
|
|
133
|
+
owned.set(token, renewed);
|
|
134
|
+
else
|
|
135
|
+
owned.delete(token);
|
|
136
|
+
}
|
|
137
|
+
return renewed;
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
release(lease) {
|
|
141
|
+
validateOwnedScope(lease);
|
|
142
|
+
validateToken(lease.token);
|
|
143
|
+
const snapshot = { ...lease, scope: { ...lease.scope } };
|
|
144
|
+
return operation(() => releaseOwned(snapshot));
|
|
145
|
+
},
|
|
146
|
+
withLease(input, execute, runOptions = {}) {
|
|
147
|
+
validateAcquire(input);
|
|
148
|
+
input = { ...input, scope: { ...input.scope } };
|
|
149
|
+
runOptions = { ...runOptions };
|
|
150
|
+
if (runOptions.renewEveryMs !== undefined) {
|
|
151
|
+
positiveInteger(runOptions.renewEveryMs);
|
|
152
|
+
if (runOptions.renewEveryMs >= input.ttlMs)
|
|
153
|
+
throw new LimitError2("invalid-input");
|
|
154
|
+
}
|
|
155
|
+
if (closed)
|
|
156
|
+
return Promise.reject(new LimitError2("closed"));
|
|
157
|
+
const controller = new AbortController;
|
|
158
|
+
const abort = () => controller.abort(runOptions.signal?.reason);
|
|
159
|
+
if (runOptions.signal?.aborted)
|
|
160
|
+
abort();
|
|
161
|
+
else
|
|
162
|
+
runOptions.signal?.addEventListener("abort", abort, { once: true });
|
|
163
|
+
let timer;
|
|
164
|
+
let renewal;
|
|
165
|
+
let renewalError;
|
|
166
|
+
let renewalFailed = false;
|
|
167
|
+
let finishing = false;
|
|
168
|
+
const stopRenewal = () => {
|
|
169
|
+
if (timer !== undefined)
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
};
|
|
172
|
+
controller.signal.addEventListener("abort", stopRenewal, { once: true });
|
|
173
|
+
async function run() {
|
|
174
|
+
let lease = null;
|
|
175
|
+
let value;
|
|
176
|
+
const errors = [];
|
|
177
|
+
try {
|
|
178
|
+
let scheduleRenewal = function() {
|
|
179
|
+
if (!lease || finishing || controller.signal.aborted || !runOptions.renewEveryMs)
|
|
180
|
+
return;
|
|
181
|
+
timer = setTimeout(() => {
|
|
182
|
+
renewal = (async () => {
|
|
183
|
+
try {
|
|
184
|
+
const renewed = await service.renew(lease, input.ttlMs);
|
|
185
|
+
if (!renewed)
|
|
186
|
+
throw new LimitError2("lease-lost");
|
|
187
|
+
lease = renewed;
|
|
188
|
+
scheduleRenewal();
|
|
189
|
+
} catch (error) {
|
|
190
|
+
renewalFailed = true;
|
|
191
|
+
renewalError = error;
|
|
192
|
+
controller.abort(error);
|
|
193
|
+
}
|
|
194
|
+
})();
|
|
195
|
+
}, runOptions.renewEveryMs);
|
|
196
|
+
};
|
|
197
|
+
controller.signal.throwIfAborted();
|
|
198
|
+
const acquisition = await service.acquire(input);
|
|
199
|
+
lease = acquisition.lease;
|
|
200
|
+
if (!acquisition.allowed)
|
|
201
|
+
throw new LimitError2("denied");
|
|
202
|
+
controller.signal.throwIfAborted();
|
|
203
|
+
scheduleRenewal();
|
|
204
|
+
value = await execute({ signal: controller.signal, lease });
|
|
205
|
+
controller.signal.throwIfAborted();
|
|
206
|
+
} catch (error) {
|
|
207
|
+
errors.push(error);
|
|
208
|
+
} finally {
|
|
209
|
+
finishing = true;
|
|
210
|
+
stopRenewal();
|
|
211
|
+
await renewal;
|
|
212
|
+
if (renewalFailed && !errors.includes(renewalError))
|
|
213
|
+
errors.push(renewalError);
|
|
214
|
+
if (lease) {
|
|
215
|
+
try {
|
|
216
|
+
await releaseOwned(lease);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
errors.push(error);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
runOptions.signal?.removeEventListener("abort", abort);
|
|
222
|
+
controller.signal.removeEventListener("abort", stopRenewal);
|
|
223
|
+
}
|
|
224
|
+
if (errors.length === 1)
|
|
225
|
+
throw errors[0];
|
|
226
|
+
if (errors.length > 1)
|
|
227
|
+
throw new AggregateError(errors, "Lease execution and cleanup failed");
|
|
228
|
+
return value;
|
|
229
|
+
}
|
|
230
|
+
const promise = run();
|
|
231
|
+
runs.set(controller, promise);
|
|
232
|
+
promise.then(() => runs.delete(controller), () => runs.delete(controller));
|
|
233
|
+
return promise;
|
|
234
|
+
},
|
|
235
|
+
close() {
|
|
236
|
+
if (!closing) {
|
|
237
|
+
closed = true;
|
|
238
|
+
closing = Promise.resolve().then(async () => {
|
|
239
|
+
for (const controller of runs.keys())
|
|
240
|
+
controller.abort(new LimitError2("closed"));
|
|
241
|
+
await Promise.allSettled([...runs.values(), ...pending]);
|
|
242
|
+
const results = await Promise.allSettled([...owned.values()].map(releaseOwned));
|
|
243
|
+
const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
244
|
+
if (errors.length)
|
|
245
|
+
throw new AggregateError(errors, "Limit lease cleanup failed");
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return closing;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
return service;
|
|
252
|
+
}
|
|
253
|
+
// src/memory.ts
|
|
254
|
+
function createMemoryLimitStore(options = {}) {
|
|
255
|
+
const now = options.now ?? Date.now;
|
|
256
|
+
const counters = new Map;
|
|
257
|
+
const concurrency = new Map;
|
|
258
|
+
return {
|
|
259
|
+
async consume(kind, input) {
|
|
260
|
+
validateKind(kind);
|
|
261
|
+
validateConsume(input);
|
|
262
|
+
const key = JSON.stringify([kind, scopeKey(input.scope)]);
|
|
263
|
+
const { state, result } = consumeState(counters.get(key), input, now());
|
|
264
|
+
counters.set(key, state);
|
|
265
|
+
return result;
|
|
266
|
+
},
|
|
267
|
+
async acquire(input, token) {
|
|
268
|
+
validateAcquire(input);
|
|
269
|
+
validateToken(token);
|
|
270
|
+
const key = scopeKey(input.scope);
|
|
271
|
+
const state = liveState(concurrency.get(key), input.capacity, now());
|
|
272
|
+
const result = acquireState(state, input, token);
|
|
273
|
+
concurrency.set(key, state);
|
|
274
|
+
return result;
|
|
275
|
+
},
|
|
276
|
+
async renew(scope, token, ttlMs) {
|
|
277
|
+
const key = scopeKey(scope);
|
|
278
|
+
validateToken(token);
|
|
279
|
+
positiveInteger(ttlMs, maxDurationMs);
|
|
280
|
+
const previous = concurrency.get(key);
|
|
281
|
+
if (!previous)
|
|
282
|
+
return null;
|
|
283
|
+
const state = liveState(previous, previous.capacity, now());
|
|
284
|
+
const lease = renewState(state, scope, token, ttlMs);
|
|
285
|
+
concurrency.set(key, state);
|
|
286
|
+
return lease;
|
|
287
|
+
},
|
|
288
|
+
async release(scope, token) {
|
|
289
|
+
const key = scopeKey(scope);
|
|
290
|
+
validateToken(token);
|
|
291
|
+
const previous = concurrency.get(key);
|
|
292
|
+
if (!previous)
|
|
293
|
+
return;
|
|
294
|
+
const state = liveState(previous, previous.capacity, now());
|
|
295
|
+
state.leases = state.leases.filter((lease) => lease.token !== token);
|
|
296
|
+
concurrency.set(key, state);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/index.ts
|
|
302
|
+
function createLimitsPlugin(options) {
|
|
303
|
+
return bindConfig(limitConfig, options.config, {
|
|
304
|
+
id: options.id,
|
|
305
|
+
requires: options.requires,
|
|
306
|
+
async setup(context, config) {
|
|
307
|
+
const store = await options.connect(context);
|
|
308
|
+
const limits = createLimits({ store, config, logger: context.logger });
|
|
309
|
+
const close = context.onCleanup(() => limits.close());
|
|
310
|
+
return { ...limits, close };
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
export {
|
|
315
|
+
LimitError2 as LimitError,
|
|
316
|
+
createLimits,
|
|
317
|
+
createLimitsPlugin,
|
|
318
|
+
createMemoryLimitStore,
|
|
319
|
+
limitConfig,
|
|
320
|
+
limitConfigSchema
|
|
321
|
+
};
|
package/dist/memory.d.ts
ADDED