@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prsm/entitle",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "Plan-based entitlements and feature gating, resolved at runtime, backed by postgres",
5
5
  "type": "module",
6
6
  "exports": {
@@ -49,9 +49,13 @@ function assertBoolean(scope, key, v) {
49
49
  }
50
50
 
51
51
  function assertLimitValue(scope, key, v) {
52
- if (v !== null && (typeof v !== "number" || !Number.isFinite(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
- const current = (await driver.getState(subject))?.override ?? {}
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
- cache.invalidate(subject)
249
- return
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
- async limit(subject, key) {
302
- requireLimit(key)
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 this.limit(subject, key)
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
@@ -27,8 +27,22 @@ export function memoryDriver() {
27
27
  assignments.delete(subject)
28
28
  },
29
29
 
30
- async setOverride(subject, data) {
31
- overrides.set(subject, data)
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) {
@@ -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 setOverride(subject, data) {
59
+ async mergeOverride(subject, delta) {
60
60
  await pool.query(
61
- `insert into ${overrides} (subject, data) values ($1, $2)
62
- on conflict (subject) do update set data = excluded.data, updated_at = now()`,
63
- [subject, JSON.stringify(data)],
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