@inixiative/json-rules 2.5.0 → 2.6.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/README.md CHANGED
@@ -41,7 +41,9 @@ check(rule, { age: 16 }); // "Must be 18 or older"
41
41
  - `if` / `then` / `else`
42
42
  - array validation against nested object elements
43
43
  - array aggregates — `sum` and `avg` across numeric arrays or relation lists
44
+ - ordered windowing — first/last `N` with `orderBy` / `take` / `skip` (check-only)
44
45
  - date comparisons with timezone-aware runtime evaluation
46
+ - relative & calendar date expressions — "last 30 days", "this month" — via `within` and `ago`/`ahead`/`this`/`last`/`next`
45
47
  - relative value references via `path`
46
48
  - custom error messages on every rule
47
49
  - compilation to Prisma and PostgreSQL for supported subsets
@@ -97,6 +99,7 @@ Supported comparison operators for aggregate rules: `equals`, `notEquals`, `less
97
99
  - `after`
98
100
  - `onOrBefore`
99
101
  - `onOrAfter`
102
+ - `within`
100
103
  - `between`
101
104
  - `notBetween`
102
105
  - `dayIn`
@@ -208,6 +211,97 @@ Empty-array semantics: `sum([]) = 0`, `avg([]) = null` (comparison fails).
208
211
  }
209
212
  ```
210
213
 
214
+ ### Relative & Calendar Date Expressions
215
+
216
+ A date rule's `value` can be a structured, serializable expression instead of an
217
+ absolute date. Magnitudes are always **positive** — direction lives in the keyword.
218
+ Units are dayjs words: `day`, `week`, `isoWeek`, `month`, `quarter`, `year`,
219
+ `hour`, `minute`, `second`.
220
+
221
+ **Point expressions** — pair with `before` / `after` / `onOrBefore` / `onOrAfter`,
222
+ or as `between` endpoints:
223
+
224
+ ```ts
225
+ // "more than 30 days ago"
226
+ { field: 'completedAt', dateOperator: DateOperator.before, value: { ago: { days: 30 } } }
227
+
228
+ // "within the next 2 months"
229
+ { field: 'dueAt', dateOperator: DateOperator.after, value: { ahead: { months: 2 } } }
230
+
231
+ // a named edge of a calendar period
232
+ { field: 'completedAt', dateOperator: DateOperator.before, value: { end: { last: 'month' } } }
233
+ ```
234
+
235
+ **Range expressions** — pair with the `within` operator:
236
+
237
+ ```ts
238
+ // "this month"
239
+ { field: 'completedAt', dateOperator: DateOperator.within, value: { this: 'month' } }
240
+
241
+ // "last week", "next quarter"
242
+ { field: 'completedAt', dateOperator: DateOperator.within, value: { last: 'week' } }
243
+
244
+ // rolling window: "within the last 30 days" → [now - 30d, now]
245
+ { field: 'completedAt', dateOperator: DateOperator.within, value: { ago: { days: 30 } } }
246
+ ```
247
+
248
+ A **bare period** with `before` / `after` resolves to the only sensible edge —
249
+ `before { last: 'month' }` is *before the start* of last month, `after { next: 'month' }`
250
+ is *after the end* of next month. Use `{ start: … }` / `{ end: … }` for the other edge.
251
+
252
+ #### The `now` contract and config
253
+
254
+ Relative/calendar expressions need a reference instant. `now` is an **explicit
255
+ evaluator input** — there is no implicit `Date.now()` inside the library. Pass it
256
+ on the same options bag as everything else; `check`/`toPrisma`/`toSql` **throw** if
257
+ a relative expression is used without it.
258
+
259
+ ```ts
260
+ check(rule, data, { now, timeZone: 'America/New_York', weekStart: 'sunday' });
261
+ toPrisma(rule, { map, model, now });
262
+ toSql(rule, { now });
263
+ ```
264
+
265
+ | Option | Default | Governs |
266
+ | --- | --- | --- |
267
+ | `now` | — (required when a relative/period expression is present) | the anchor instant |
268
+ | `timeZone` | `'UTC'` | how `now` and period boundaries localize |
269
+ | `weekStart` | `'monday'` (ISO / isoWeek) | start of `week` for `this`/`last`/`next` |
270
+
271
+ Compilers resolve expressions to concrete `Date` bounds at compile time, so
272
+ `check()`, `toPrisma()`, and `toSql()` all compare the same instant.
273
+
274
+ ### Windowing — first/last with `orderBy` / `take` / `skip`
275
+
276
+ Array and aggregate rules accept an ordered-window selector that runs **before** the
277
+ predicate. Pipeline: order → skip → take. Direction comes from `orderBy.dir`, so
278
+ "the last fanMission" is `order by date desc, take 1`.
279
+
280
+ ```ts
281
+ // "user whose last fanMission was more than 30 days ago"
282
+ {
283
+ field: 'fanMissions',
284
+ orderBy: [{ field: 'completedAt', dir: 'desc' }],
285
+ take: 1,
286
+ arrayOperator: ArrayOperator.all,
287
+ condition: { field: 'completedAt', dateOperator: DateOperator.before, value: { ago: { days: 30 } } },
288
+ }
289
+ ```
290
+
291
+ `orderBy` is a non-empty array of `{ field, dir: 'asc' | 'desc' }` (multi-key);
292
+ `take`/`skip` are non-negative integers. **Empty-window semantics are author-driven**:
293
+ `all` is vacuously true on an empty window, `atLeast: 1` (or `any`) is false. To require
294
+ "the windowed element matches **and** one exists," combine `all` with `notEmpty` / `atLeast: 1`.
295
+
296
+ > **Compilation.** `toPrisma()` compiles the **extremal** case — `take: 1`, a single
297
+ > `orderBy`, and a monotonic condition on that same field, with the direction aligned so
298
+ > the extremal element is binding (`all` + desc + `before`, `any` + desc + `after`, etc.).
299
+ > It rewrites to `every` / `some` (e.g. the rule above → `{ fanMissions: { every: { completedAt:
300
+ > { lt: <now-30d> } } } }`). Any other windowed rule — `take > 1`, `skip`, multi-key
301
+ > `orderBy`, a different/non-monotonic condition, or a misaligned direction — throws a clear
302
+ > "unsupported" error. `toSql()` does not compile windowing at all (no relation subqueries in
303
+ > a `WHERE` fragment). Evaluate the unsupported cases in memory with `check()`.
304
+
211
305
  ## Path Semantics
212
306
 
213
307
  `path` lets a rule resolve its comparison value from somewhere other than `value`.
@@ -393,7 +487,9 @@ Not every backend supports every rule shape.
393
487
  | Aggregate `sum` / `avg` — primitive or object array | Yes | No | Yes |
394
488
  | Aggregate `sum` / `avg` — relation list | Yes | Yes, with `map` + `model` | No |
395
489
  | Date comparisons | Yes | Most | Yes |
490
+ | Date expressions (`ago`/`ahead`/`this`/`last`/`next`/`start`/`end`) + `within` | Yes | Yes | Yes |
396
491
  | `dayIn` / `dayNotIn` | Yes | No | Yes |
492
+ | Windowing (`orderBy` / `take` / `skip`) | Yes | Extremal (`take:1`, aligned) | No |
397
493
  | `path: '$.field'` current-element / same-row refs | Yes | No | Yes |
398
494
 
399
495
  ### Prisma Limitations