@prsm/entitle 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/entitlements.js +17 -25
- package/src/memoryDriver.js +16 -2
- package/src/postgresDriver.js +23 -4
package/README.md
CHANGED
|
@@ -91,6 +91,7 @@ const entitlements = createEntitlements({
|
|
|
91
91
|
meter,
|
|
92
92
|
})
|
|
93
93
|
|
|
94
|
+
// account.id is assigned the "pro" plan, whose tokens limit is 5_000_000
|
|
94
95
|
const quota = await entitlements.check(account.id, "tokens")
|
|
95
96
|
// { allowed: true, used: 84210, remaining: 4915790, limit: 5000000, unit: "tokens", feature: "tokens" }
|
|
96
97
|
|
|
@@ -117,7 +118,7 @@ The **plan catalog** is declared once at construction, the way you declare your
|
|
|
117
118
|
|
|
118
119
|
**Assignments** (which plan a subject is on) and **overrides** (per-subject adjustments) live in postgres and are mutable at runtime. An override shallow-merges over the plan, so you specify only what differs. A subject with no assignment gets `defaultPlan`; `unassign(subject)` removes an assignment and reverts the subject to the default (distinct from assigning the default explicitly, which would not follow a later change to `defaultPlan`).
|
|
119
120
|
|
|
120
|
-
Overrides accumulate: each `override()` call merges into the subject's existing override rather than replacing it, so granting more seats and later enabling a feature leaves both in place, and overriding a key again updates just that key. `clearOverride(subject)` removes the whole override; `clearOverride(subject, { limits: ["seats"] })` reverts only the named keys and keeps the rest.
|
|
121
|
+
Overrides accumulate: each `override()` call merges into the subject's existing override rather than replacing it, so granting more seats and later enabling a feature leaves both in place, and overriding a key again updates just that key. The merge happens in the database under a row lock, so two concurrent overrides to the same subject both land instead of one clobbering the other. `clearOverride(subject)` removes the whole override; `clearOverride(subject, { limits: ["seats"] })` reverts only the named keys and keeps the rest.
|
|
121
122
|
|
|
122
123
|
```js
|
|
123
124
|
await entitlements.override(account.id, { limits: { seats: 50 } })
|
|
@@ -132,7 +133,7 @@ Resolved entitlements are cached per subject for a short, configurable window (`
|
|
|
132
133
|
|
|
133
134
|
### `createEntitlements({ driver, plans, defaultPlan, features?, limits?, meter?, cacheTtl?, tracer? })`
|
|
134
135
|
|
|
135
|
-
Creates a resolver. `driver` is `postgresDriver({ pool })` or `memoryDriver()`. `plans` is the catalog; `defaultPlan` must be one of its keys. `features` and `limits` declare the universe of valid keys (defaulting to the union across plans); when given, plans may only reference declared keys. `meter` is an optional `@prsm/meter` instance, required only for `check()`. `cacheTtl` accepts ms or a string like `"10s"`. `tracer` is an optional `@prsm/trace` tracer.
|
|
136
|
+
Creates a resolver. `driver` is `postgresDriver({ pool })` or `memoryDriver()`. `plans` is the catalog; `defaultPlan` must be one of its keys. `features` and `limits` declare the universe of valid keys (defaulting to the union across plans); when given, plans may only reference declared keys. `meter` is an optional `@prsm/meter` instance, required only for `check()`. `cacheTtl` accepts ms or a string like `"10s"`. `tracer` is an optional `@prsm/trace` tracer; `can()` and `check()` are wrapped in spans when it is present.
|
|
136
137
|
|
|
137
138
|
### `entitlements.setup()`
|
|
138
139
|
|
package/package.json
CHANGED
package/src/entitlements.js
CHANGED
|
@@ -49,9 +49,13 @@ function assertBoolean(scope, key, v) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function assertLimitValue(scope, key, v) {
|
|
52
|
-
if (v
|
|
52
|
+
if (v === null) return
|
|
53
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
53
54
|
throw new Error(`${scope} limit "${key}" must be a finite number or null (null means unlimited)`)
|
|
54
55
|
}
|
|
56
|
+
if (v < 0) {
|
|
57
|
+
throw new Error(`${scope} limit "${key}" must not be negative (use 0 to deny, null for unlimited)`)
|
|
58
|
+
}
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
/**
|
|
@@ -184,6 +188,12 @@ export function createEntitlements(options = {}) {
|
|
|
184
188
|
return effective
|
|
185
189
|
}
|
|
186
190
|
|
|
191
|
+
async function resolveLimit(subject, key) {
|
|
192
|
+
requireLimit(key)
|
|
193
|
+
const eff = await resolve(subject)
|
|
194
|
+
return key in eff.limits ? eff.limits[key] : 0
|
|
195
|
+
}
|
|
196
|
+
|
|
187
197
|
return {
|
|
188
198
|
/** Create the backing tables if they do not exist. Idempotent. */
|
|
189
199
|
setup() {
|
|
@@ -226,11 +236,7 @@ export function createEntitlements(options = {}) {
|
|
|
226
236
|
async override(subject, data) {
|
|
227
237
|
if (!subject) throw new Error("override requires a `subject`")
|
|
228
238
|
validateOverride(data, featureUniverse, limitUniverse)
|
|
229
|
-
|
|
230
|
-
await driver.setOverride(subject, {
|
|
231
|
-
features: { ...(current.features ?? {}), ...(data.features ?? {}) },
|
|
232
|
-
limits: { ...(current.limits ?? {}), ...(data.limits ?? {}) },
|
|
233
|
-
})
|
|
239
|
+
await driver.mergeOverride(subject, { features: data.features ?? {}, limits: data.limits ?? {} })
|
|
234
240
|
cache.invalidate(subject)
|
|
235
241
|
},
|
|
236
242
|
|
|
@@ -245,20 +251,8 @@ export function createEntitlements(options = {}) {
|
|
|
245
251
|
if (!subject) throw new Error("clearOverride requires a `subject`")
|
|
246
252
|
if (!keys) {
|
|
247
253
|
await driver.clearOverride(subject)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
251
|
-
const current = (await driver.getState(subject))?.override
|
|
252
|
-
if (current) {
|
|
253
|
-
const features = { ...(current.features ?? {}) }
|
|
254
|
-
const limits = { ...(current.limits ?? {}) }
|
|
255
|
-
for (const k of keys.features ?? []) delete features[k]
|
|
256
|
-
for (const k of keys.limits ?? []) delete limits[k]
|
|
257
|
-
if (Object.keys(features).length === 0 && Object.keys(limits).length === 0) {
|
|
258
|
-
await driver.clearOverride(subject)
|
|
259
|
-
} else {
|
|
260
|
-
await driver.setOverride(subject, { features, limits })
|
|
261
|
-
}
|
|
254
|
+
} else {
|
|
255
|
+
await driver.removeOverrideKeys(subject, { features: keys.features ?? [], limits: keys.limits ?? [] })
|
|
262
256
|
}
|
|
263
257
|
cache.invalidate(subject)
|
|
264
258
|
},
|
|
@@ -298,10 +292,8 @@ export function createEntitlements(options = {}) {
|
|
|
298
292
|
* @param {string} key
|
|
299
293
|
* @returns {Promise<number|null>}
|
|
300
294
|
*/
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const eff = await resolve(subject)
|
|
304
|
-
return key in eff.limits ? eff.limits[key] : 0
|
|
295
|
+
limit(subject, key) {
|
|
296
|
+
return resolveLimit(subject, key)
|
|
305
297
|
},
|
|
306
298
|
|
|
307
299
|
/**
|
|
@@ -319,7 +311,7 @@ export function createEntitlements(options = {}) {
|
|
|
319
311
|
throw new Error("check requires a `meter`; pass it to createEntitlements, or use limit() for the static ceiling")
|
|
320
312
|
}
|
|
321
313
|
return traced(tracer, "entitle.check", { "entitle.subject": subject, "entitle.feature": key }, async () => {
|
|
322
|
-
const limit = await
|
|
314
|
+
const limit = await resolveLimit(subject, key)
|
|
323
315
|
const usage = await meter.usage({ ...usageQuery, subject, metric: key })
|
|
324
316
|
const used = usage.quantity
|
|
325
317
|
const allowed = limit === null || used < limit
|
package/src/memoryDriver.js
CHANGED
|
@@ -27,8 +27,22 @@ export function memoryDriver() {
|
|
|
27
27
|
assignments.delete(subject)
|
|
28
28
|
},
|
|
29
29
|
|
|
30
|
-
async
|
|
31
|
-
overrides.
|
|
30
|
+
async mergeOverride(subject, delta) {
|
|
31
|
+
const current = overrides.get(subject) ?? { features: {}, limits: {} }
|
|
32
|
+
overrides.set(subject, {
|
|
33
|
+
features: { ...(current.features ?? {}), ...(delta.features ?? {}) },
|
|
34
|
+
limits: { ...(current.limits ?? {}), ...(delta.limits ?? {}) },
|
|
35
|
+
})
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async removeOverrideKeys(subject, keys) {
|
|
39
|
+
const current = overrides.get(subject)
|
|
40
|
+
if (!current) return
|
|
41
|
+
const features = { ...(current.features ?? {}) }
|
|
42
|
+
const limits = { ...(current.limits ?? {}) }
|
|
43
|
+
for (const k of keys.features ?? []) delete features[k]
|
|
44
|
+
for (const k of keys.limits ?? []) delete limits[k]
|
|
45
|
+
overrides.set(subject, { features, limits })
|
|
32
46
|
},
|
|
33
47
|
|
|
34
48
|
async clearOverride(subject) {
|
package/src/postgresDriver.js
CHANGED
|
@@ -56,11 +56,30 @@ export function postgresDriver(options = {}) {
|
|
|
56
56
|
await pool.query(`delete from ${assignments} where subject = $1`, [subject])
|
|
57
57
|
},
|
|
58
58
|
|
|
59
|
-
async
|
|
59
|
+
async mergeOverride(subject, delta) {
|
|
60
60
|
await pool.query(
|
|
61
|
-
`insert into ${overrides} (subject, data)
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
`insert into ${overrides} (subject, data)
|
|
62
|
+
values ($1, jsonb_build_object('features', $2::jsonb, 'limits', $3::jsonb))
|
|
63
|
+
on conflict (subject) do update set
|
|
64
|
+
data = jsonb_build_object(
|
|
65
|
+
'features', coalesce(${overrides}.data -> 'features', '{}'::jsonb) || (excluded.data -> 'features'),
|
|
66
|
+
'limits', coalesce(${overrides}.data -> 'limits', '{}'::jsonb) || (excluded.data -> 'limits')
|
|
67
|
+
),
|
|
68
|
+
updated_at = now()`,
|
|
69
|
+
[subject, JSON.stringify(delta.features ?? {}), JSON.stringify(delta.limits ?? {})],
|
|
70
|
+
)
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async removeOverrideKeys(subject, keys) {
|
|
74
|
+
await pool.query(
|
|
75
|
+
`update ${overrides} set
|
|
76
|
+
data = jsonb_build_object(
|
|
77
|
+
'features', coalesce(data -> 'features', '{}'::jsonb) - $2::text[],
|
|
78
|
+
'limits', coalesce(data -> 'limits', '{}'::jsonb) - $3::text[]
|
|
79
|
+
),
|
|
80
|
+
updated_at = now()
|
|
81
|
+
where subject = $1`,
|
|
82
|
+
[subject, keys.features ?? [], keys.limits ?? []],
|
|
64
83
|
)
|
|
65
84
|
},
|
|
66
85
|
|