@classytic/repo-core 0.18.0 → 0.20.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/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to `@classytic/repo-core` are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.20.0] - 2026-08-04
8
+
9
+ ### Added
10
+
11
+ - **`ResolveBetterAuthCollectionsOptions.exclude`** — canonical collection names
12
+ to omit from `resolveBetterAuthCollections()` output. Applied before
13
+ `modelOverrides` / `usePlural` so a caller names the canonical collection
14
+ (`'user'`) and never has to predict the final model name. Solves the collision
15
+ when a host registers stub models for BA collections it merely references and a
16
+ full `createBetterAuthOverlay` for the one it exposes CRUD on — both for the
17
+ same collection would cause mongoose to lock the schema on first `model()`,
18
+ silently dropping `additionalFields` and causing the overlay to refuse.
19
+
20
+ ## [0.19.0] - 2026-07-29
21
+
22
+ ### Added — `coerceFilterDates` + ISO date helpers (`./filter`)
23
+
24
+ - **`coerceFilterDates(filter)`** — walks a record-shape filter (Mongo-dialect or bare bracket-op syntax) and coerces ISO-8601 strings on range operators (`gt`/`gte`/`lt`/`lte` and `$`-prefixed equivalents) to `Date`, recursing through `$and`/`$or`/`$nor`/`$not` logical wrappers. Returns a new object; the input is never mutated. Equality operators are deliberately excluded — a string that happens to look like a date is far more likely a string id than a date equality predicate. Fixes silent empty results on aggregation `$match` stages: MongoDB `$match` (unlike `find`) performs no schema casting, so a string compared to a Date column matches nothing.
25
+ - **`tryCoerceIsoDate(value)`** — coerces one unknown value to a `Date` when it is an unambiguous ISO-8601 string; returns anything else untouched. Safe to apply unconditionally.
26
+ - **`ISO_DATE_PATTERN`** — the single regex source of truth for tight ISO-8601 detection (date-only through millisecond precision + optional timezone). `query-parser/coerce.ts` now imports this instead of keeping a duplicate pattern — one definition means the URL boundary and the compile boundary can never disagree on what "looks like a date".
27
+
28
+ All three are exported from `@classytic/repo-core/filter`. Purely additive.
29
+
7
30
  ## [0.18.0] - 2026-07-27
8
31
 
9
32
  ### Added — `definePurgeStep` builder (`./cleanup`) and `resolveTenantField` (`./tenant`)
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Classytic
3
+ Copyright (c) 2026 Classytic LLC
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -239,3 +239,10 @@ See [docs/data-lifecycle.md](./docs/data-lifecycle.md) for the billion-row runbo
239
239
  ## License
240
240
 
241
241
  MIT — see [LICENSE](./LICENSE).
242
+
243
+
244
+ ## Trademark
245
+
246
+ The code is MIT-licensed. **"Classytic", "arc", and the logos are trademarks of
247
+ Classytic LLC** and are **not** licensed under MIT — see [TRADEMARK.md](TRADEMARK.md).
248
+ Forks must be renamed; the license covers the code, not the brand.
@@ -71,6 +71,18 @@ interface ResolveBetterAuthCollectionsOptions {
71
71
  * @default []
72
72
  */
73
73
  plugins?: BetterAuthPluginKey[];
74
+ /**
75
+ * CANONICAL collection names to omit from the result.
76
+ *
77
+ * The reason this exists: a host commonly needs stub models for the BA collections it merely
78
+ * REFERENCES and a full `createBetterAuthOverlay` for the one it exposes CRUD on. Registering both
79
+ * for the same collection collides — mongoose locks a schema on first `model()`, so the overlay's
80
+ * `additionalFields` would be silently dropped, and the overlay therefore refuses.
81
+ *
82
+ * Applied BEFORE `modelOverrides` / `usePlural`, so a caller names the canonical collection and
83
+ * never has to predict the final model name.
84
+ */
85
+ exclude?: string[];
74
86
  /**
75
87
  * Additional collection names beyond the built-in plugin set.
76
88
  *
@@ -52,7 +52,8 @@ function pluralizeBetterAuthCollection(name) {
52
52
  * collection names. `core` is always included.
53
53
  */
54
54
  function resolveBetterAuthCollections(options = {}) {
55
- const { plugins = [], extraCollections = [], usePlural = false, modelOverrides = {} } = options;
55
+ const { plugins = [], extraCollections = [], usePlural = false, modelOverrides = {}, exclude = [] } = options;
56
+ const excluded = new Set(exclude);
56
57
  const pluginSet = /* @__PURE__ */ new Set(["core", ...plugins]);
57
58
  const collected = [];
58
59
  for (const key of pluginSet) for (const name of BA_COLLECTIONS_BY_PLUGIN[key]) collected.push(name);
@@ -62,6 +63,12 @@ function resolveBetterAuthCollections(options = {}) {
62
63
  for (const canonical of collected) {
63
64
  if (seen.has(canonical)) continue;
64
65
  seen.add(canonical);
66
+ /**
67
+ * Excluded HERE, while the CANONICAL name is still in hand — before `modelOverrides` /
68
+ * `usePlural` produce the final name. A caller excluding `'user'` should not have to predict
69
+ * whether that becomes `users` or something a `modelName` override renamed it to.
70
+ */
71
+ if (excluded.has(canonical)) continue;
65
72
  const finalName = modelOverrides[canonical] ?? (usePlural ? pluralizeBetterAuthCollection(canonical) : canonical);
66
73
  unique.push(finalName);
67
74
  }
@@ -0,0 +1,15 @@
1
+ //#region src/cache/stable-stringify.d.ts
2
+ /**
3
+ * Deterministic JSON stringify — equivalent values produce identical output.
4
+ *
5
+ * Used by kits that build cache keys from hook contexts so
6
+ * `{ b: 1, a: 2 }` and `{ a: 2, b: 1 }` hash to the same bucket. Arrays
7
+ * preserve order (order is part of array identity).
8
+ *
9
+ * Extracted from the mongokit / sqlitekit cache plugins into repo-core so
10
+ * every kit's cachePlugin uses the same keying rule — cross-kit caches
11
+ * remain bucket-compatible.
12
+ */
13
+ declare function stableStringify(value: unknown): string;
14
+ //#endregion
15
+ export { stableStringify };
@@ -0,0 +1,87 @@
1
+ //#region src/filter/coerce-dates.d.ts
2
+ /**
3
+ * ISO-date coercion for record-shape filters — the canonical helper every
4
+ * kit shares.
5
+ *
6
+ * WHY THIS EXISTS. A URL carries strings (`?createdAt[gte]=2026-04-01`), but
7
+ * the stored column is a real date. On a `find`-family call Mongoose casts
8
+ * the query against the schema, so a string silently becomes a `Date` and
9
+ * everything works. **An aggregation `$match` stage gets no such casting** —
10
+ * and BSON type ordering makes `Date` (type 9) and `String` (type 2)
11
+ * non-comparable, so `{ createdAt: { $gte: '2026-04-01' } }` matches
12
+ * NOTHING against a Date field. Silent empty result, no error. The same
13
+ * hazard exists for any kit comparing a typed column to a string literal.
14
+ *
15
+ * Coercion therefore has to happen in the shared layer, before a kit emits
16
+ * its native predicate — which is what this module is for. It is
17
+ * DIALECT-PRESERVING (record in, record out): it never converts to the
18
+ * Filter IR, so a caller that hands over Mongo-dialect syntax gets
19
+ * Mongo-dialect syntax back, just with dates typed correctly. That keeps it
20
+ * usable from `compileFilterToMongo`'s already-built-query passthrough
21
+ * branch, where converting to IR would lose operators the IR doesn't model.
22
+ *
23
+ * Two shapes are handled, because both reach the compile boundary:
24
+ * - **bare shorthand** — `{ gte: '…' }`, what Fastify parses out of arc's
25
+ * bracket-syntax URL params, and
26
+ * - **`$`-prefixed** — `{ $gte: '…' }`, already-built Mongo/policy syntax.
27
+ *
28
+ * Logical wrappers (`$and` / `$or` / `$nor` / `$not`) are recursed — the
29
+ * same operator set {@link policyRecordToFilter} walks, kept deliberately in
30
+ * sync. Without recursion a date range nested under `$and` (exactly what a
31
+ * tenant/policy-scope merge produces when it conjoins a policy filter with a
32
+ * caller filter) is never coerced.
33
+ */
34
+ /**
35
+ * Tight ISO-8601 pattern — date-only through millisecond precision with an
36
+ * optional timezone. Anchored at BOTH ends on purpose: a loose prefix-only
37
+ * match (`/^\d{4}-\d{2}-\d{2}/`) also swallows strings that merely START
38
+ * with something date-shaped (order numbers, slugs, serials), silently
39
+ * rewriting a legitimate string predicate into a Date one.
40
+ *
41
+ * THE single source of truth — `query-parser/coerce.ts` imports it rather
42
+ * than keeping a second copy.
43
+ */
44
+ declare const ISO_DATE_PATTERN: RegExp;
45
+ /**
46
+ * Coerce one value to a `Date` when it is an unambiguous ISO-8601 string.
47
+ * Anything else — including an unparseable date-shaped string — is returned
48
+ * untouched, so this is always safe to apply.
49
+ */
50
+ declare function tryCoerceIsoDate(value: unknown): unknown;
51
+ /**
52
+ * Walk a record-shape filter and coerce ISO-date strings on range operators
53
+ * to `Date`, recursing through logical wrappers. Returns a new object;
54
+ * the input is never mutated. Non-range operators, real nested documents,
55
+ * and already-typed values pass through unchanged.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * coerceFilterDates({ $and: [{ createdAt: { $gte: '2026-04-01' } }] })
60
+ * // → { $and: [{ createdAt: { $gte: Date(2026-04-01) } }] }
61
+ * ```
62
+ */
63
+ interface CoerceFilterDatesOptions {
64
+ /**
65
+ * Schema oracle: given a field path, is it actually date-typed?
66
+ *
67
+ * WITHOUT this the coercion is a guess based only on how the VALUE looks, and the guess
68
+ * is wrong for any string column whose contents happen to be ISO-shaped — a civil date
69
+ * (`'2026-08-02'`), a version, a period key. Coercing there produces the exact failure
70
+ * this function exists to prevent, in reverse: the bound becomes a Date, BSON will not
71
+ * compare Date to String, and the range silently matches NOTHING.
72
+ *
73
+ * That cost real time. A sales-fact reconciler filtered `civilDate` — a `String` field
74
+ * holding `'YYYY-MM-DD'` — with `$gte`/`$lte`. The bounds were coerced to Dates, the
75
+ * aggregate returned zero rows for every window, and the report therefore accused the
76
+ * PROJECTOR of having written nothing, for every cell, forever. The projection was
77
+ * correct the whole time; the reconciler was comparing a Date to a String.
78
+ *
79
+ * Return `false` to leave the operand exactly as given. Omit the option entirely to keep
80
+ * the old value-shape-only behaviour (correct for callers with no schema to consult,
81
+ * such as a URL query parser).
82
+ */
83
+ isDateField?: (field: string) => boolean;
84
+ }
85
+ declare function coerceFilterDates(filter: Record<string, unknown>, options?: CoerceFilterDatesOptions): Record<string, unknown>;
86
+ //#endregion
87
+ export { CoerceFilterDatesOptions, ISO_DATE_PATTERN, coerceFilterDates, tryCoerceIsoDate };
@@ -0,0 +1,114 @@
1
+ //#region src/filter/coerce-dates.ts
2
+ /**
3
+ * ISO-date coercion for record-shape filters — the canonical helper every
4
+ * kit shares.
5
+ *
6
+ * WHY THIS EXISTS. A URL carries strings (`?createdAt[gte]=2026-04-01`), but
7
+ * the stored column is a real date. On a `find`-family call Mongoose casts
8
+ * the query against the schema, so a string silently becomes a `Date` and
9
+ * everything works. **An aggregation `$match` stage gets no such casting** —
10
+ * and BSON type ordering makes `Date` (type 9) and `String` (type 2)
11
+ * non-comparable, so `{ createdAt: { $gte: '2026-04-01' } }` matches
12
+ * NOTHING against a Date field. Silent empty result, no error. The same
13
+ * hazard exists for any kit comparing a typed column to a string literal.
14
+ *
15
+ * Coercion therefore has to happen in the shared layer, before a kit emits
16
+ * its native predicate — which is what this module is for. It is
17
+ * DIALECT-PRESERVING (record in, record out): it never converts to the
18
+ * Filter IR, so a caller that hands over Mongo-dialect syntax gets
19
+ * Mongo-dialect syntax back, just with dates typed correctly. That keeps it
20
+ * usable from `compileFilterToMongo`'s already-built-query passthrough
21
+ * branch, where converting to IR would lose operators the IR doesn't model.
22
+ *
23
+ * Two shapes are handled, because both reach the compile boundary:
24
+ * - **bare shorthand** — `{ gte: '…' }`, what Fastify parses out of arc's
25
+ * bracket-syntax URL params, and
26
+ * - **`$`-prefixed** — `{ $gte: '…' }`, already-built Mongo/policy syntax.
27
+ *
28
+ * Logical wrappers (`$and` / `$or` / `$nor` / `$not`) are recursed — the
29
+ * same operator set {@link policyRecordToFilter} walks, kept deliberately in
30
+ * sync. Without recursion a date range nested under `$and` (exactly what a
31
+ * tenant/policy-scope merge produces when it conjoins a policy filter with a
32
+ * caller filter) is never coerced.
33
+ */
34
+ /**
35
+ * Tight ISO-8601 pattern — date-only through millisecond precision with an
36
+ * optional timezone. Anchored at BOTH ends on purpose: a loose prefix-only
37
+ * match (`/^\d{4}-\d{2}-\d{2}/`) also swallows strings that merely START
38
+ * with something date-shaped (order numbers, slugs, serials), silently
39
+ * rewriting a legitimate string predicate into a Date one.
40
+ *
41
+ * THE single source of truth — `query-parser/coerce.ts` imports it rather
42
+ * than keeping a second copy.
43
+ */
44
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
45
+ /**
46
+ * Coerce one value to a `Date` when it is an unambiguous ISO-8601 string.
47
+ * Anything else — including an unparseable date-shaped string — is returned
48
+ * untouched, so this is always safe to apply.
49
+ */
50
+ function tryCoerceIsoDate(value) {
51
+ if (typeof value !== "string" || !ISO_DATE_PATTERN.test(value)) return value;
52
+ const parsed = new Date(value);
53
+ return Number.isNaN(parsed.getTime()) ? value : parsed;
54
+ }
55
+ /**
56
+ * Range operators whose operand is compared by BSON/SQL type and therefore
57
+ * must be a real date, not a string. Equality is deliberately EXCLUDED:
58
+ * `{ status: '2026-01-01' }` is far more likely a string id than a date, and
59
+ * an `eq` against a Date column is the one case a caller can express exactly
60
+ * by passing a `Date`. Both bare and `$`-prefixed spellings are listed.
61
+ */
62
+ const RANGE_OPS = /* @__PURE__ */ new Set([
63
+ "gt",
64
+ "gte",
65
+ "lt",
66
+ "lte",
67
+ "$gt",
68
+ "$gte",
69
+ "$lt",
70
+ "$lte"
71
+ ]);
72
+ /** Logical operators whose operand is an ARRAY of sub-filters. */
73
+ const LOGICAL_ARRAY_OPS = /* @__PURE__ */ new Set([
74
+ "$and",
75
+ "$or",
76
+ "$nor",
77
+ "and",
78
+ "or",
79
+ "nor"
80
+ ]);
81
+ /** Logical operators whose operand is a SINGLE nested sub-filter. */
82
+ const LOGICAL_OBJECT_OPS = /* @__PURE__ */ new Set(["$not", "not"]);
83
+ function isPlainObject(value) {
84
+ return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
85
+ }
86
+ function coerceFilterDates(filter, options) {
87
+ const out = {};
88
+ for (const [key, value] of Object.entries(filter)) {
89
+ if (LOGICAL_ARRAY_OPS.has(key) && Array.isArray(value)) {
90
+ out[key] = value.map((entry) => isPlainObject(entry) ? coerceFilterDates(entry, options) : entry);
91
+ continue;
92
+ }
93
+ if (LOGICAL_OBJECT_OPS.has(key) && isPlainObject(value)) {
94
+ out[key] = coerceFilterDates(value, options);
95
+ continue;
96
+ }
97
+ if (isPlainObject(value)) {
98
+ let changed = false;
99
+ const coerced = {};
100
+ const coercible = options?.isDateField === void 0 || options.isDateField(key);
101
+ for (const [op, operand] of Object.entries(value)) if (coercible && RANGE_OPS.has(op)) {
102
+ const next = tryCoerceIsoDate(operand);
103
+ coerced[op] = next;
104
+ if (next !== operand) changed = true;
105
+ } else coerced[op] = operand;
106
+ out[key] = changed ? coerced : value;
107
+ continue;
108
+ }
109
+ out[key] = value;
110
+ }
111
+ return out;
112
+ }
113
+ //#endregion
114
+ export { ISO_DATE_PATTERN, coerceFilterDates, tryCoerceIsoDate };
@@ -1,9 +1,10 @@
1
1
  import { Filter, FilterAnd, FilterEq, FilterExists, FilterFalse, FilterGt, FilterGte, FilterIn, FilterLike, FilterLt, FilterLte, FilterNe, FilterNin, FilterNot, FilterOp, FilterOr, FilterRaw, FilterRegex, FilterTrue } from "./types.mjs";
2
2
  import { FALSE, TRUE, and, anyOf as in_, between, contains, endsWith, eq, exists, gt, gte, iEq, invert as not, isNotNull, isNull, like, lt, lte, ne, nin, or, raw, regex, startsWith } from "./builders.mjs";
3
+ import { ISO_DATE_PATTERN, coerceFilterDates, tryCoerceIsoDate } from "./coerce-dates.mjs";
3
4
  import { recordToFilter } from "./from-record.mjs";
4
5
  import { isFilter } from "./guard.mjs";
5
6
  import { asPredicate, matchFilter } from "./match.mjs";
6
7
  import { matchesRecordFilter, policyRecordToFilter } from "./match-record.mjs";
7
8
  import { SCOPE_ANY, buildTenantScope, mergeScope } from "./scope.mjs";
8
9
  import { collectFields, mapFilter, walkFilter } from "./walk.mjs";
9
- export { FALSE, type Filter, type FilterAnd, type FilterEq, type FilterExists, type FilterFalse, type FilterGt, type FilterGte, type FilterIn, type FilterLike, type FilterLt, type FilterLte, type FilterNe, type FilterNin, type FilterNot, type FilterOp, type FilterOr, type FilterRaw, type FilterRegex, type FilterTrue, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, matchesRecordFilter, mergeScope, ne, nin, nin as noneOf, not, or, policyRecordToFilter, raw, recordToFilter, regex, startsWith, walkFilter };
10
+ export { FALSE, type Filter, type FilterAnd, type FilterEq, type FilterExists, type FilterFalse, type FilterGt, type FilterGte, type FilterIn, type FilterLike, type FilterLt, type FilterLte, type FilterNe, type FilterNin, type FilterNot, type FilterOp, type FilterOr, type FilterRaw, type FilterRegex, type FilterTrue, ISO_DATE_PATTERN, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, coerceFilterDates, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, matchesRecordFilter, mergeScope, ne, nin, nin as noneOf, not, or, policyRecordToFilter, raw, recordToFilter, regex, startsWith, tryCoerceIsoDate, walkFilter };
@@ -1,8 +1,9 @@
1
1
  import { isFilter } from "./guard.mjs";
2
2
  import { collectFields, mapFilter, walkFilter } from "./walk.mjs";
3
3
  import { FALSE, TRUE, and, anyOf as in_, between, contains, endsWith, eq, exists, gt, gte, iEq, invert as not, isNotNull, isNull, like, lt, lte, ne, nin, or, raw, regex, startsWith } from "./builders.mjs";
4
+ import { ISO_DATE_PATTERN, coerceFilterDates, tryCoerceIsoDate } from "./coerce-dates.mjs";
4
5
  import { recordToFilter } from "./from-record.mjs";
5
6
  import { asPredicate, matchFilter } from "./match.mjs";
6
7
  import { matchesRecordFilter, policyRecordToFilter } from "./match-record.mjs";
7
8
  import { SCOPE_ANY, buildTenantScope, mergeScope } from "./scope.mjs";
8
- export { FALSE, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, matchesRecordFilter, mergeScope, ne, nin, nin as noneOf, not, or, policyRecordToFilter, raw, recordToFilter, regex, startsWith, walkFilter };
9
+ export { FALSE, ISO_DATE_PATTERN, SCOPE_ANY, TRUE, and, in_ as anyOf, asPredicate, between, buildTenantScope, coerceFilterDates, collectFields, contains, endsWith, eq, exists, gt, gte, iEq, in_, not as invert, isFilter, isNotNull, isNull, like, lt, lte, mapFilter, matchFilter, matchesRecordFilter, mergeScope, ne, nin, nin as noneOf, not, or, policyRecordToFilter, raw, recordToFilter, regex, startsWith, tryCoerceIsoDate, walkFilter };
@@ -0,0 +1,14 @@
1
+ import { stableStringify } from "../cache/stable-stringify.mjs";
2
+ //#region src/hash/index.d.ts
3
+ /**
4
+ * SHA-256 hex digest of a value's canonical (key-order-independent) JSON form.
5
+ * Equivalent inputs → identical digest. `algorithm` may be any hash Node's
6
+ * `crypto` supports (default `'sha256'`); `encoding` the digest format
7
+ * (default `'hex'`).
8
+ */
9
+ declare function contentHash(value: unknown, options?: {
10
+ algorithm?: string;
11
+ encoding?: 'hex' | 'base64' | 'base64url';
12
+ }): string;
13
+ //#endregion
14
+ export { contentHash, stableStringify };
@@ -0,0 +1,31 @@
1
+ import { stableStringify } from "../cache/stable-stringify.mjs";
2
+ import { createHash } from "node:crypto";
3
+ //#region src/hash/index.ts
4
+ /**
5
+ * Content-addressing — a stable cryptographic hash of any JSON-serializable value.
6
+ *
7
+ * `contentHash(value)` produces the SAME hex digest for structurally-equal values
8
+ * regardless of object key order, so it's suitable for content-addressing:
9
+ * reproducibility snapshots, idempotency keys, ETags, and dedupe. It builds on
10
+ * {@link stableStringify} (canonical JSON) and SHA-256.
11
+ *
12
+ * This is DISTINCT from the cache module's `fnv1a64`: that is a fast,
13
+ * collision-tolerant NON-cryptographic hash for cache-key bucketing. Use
14
+ * `contentHash` when a collision would be a correctness or integrity problem
15
+ * (e.g. "does this recomputation match the stored result?").
16
+ *
17
+ * `Date` values serialize via their ISO string (JSON.stringify default), so a
18
+ * value carrying dates hashes stably across a JSON round-trip.
19
+ */
20
+ /**
21
+ * SHA-256 hex digest of a value's canonical (key-order-independent) JSON form.
22
+ * Equivalent inputs → identical digest. `algorithm` may be any hash Node's
23
+ * `crypto` supports (default `'sha256'`); `encoding` the digest format
24
+ * (default `'hex'`).
25
+ */
26
+ function contentHash(value, options = {}) {
27
+ const { algorithm = "sha256", encoding = "hex" } = options;
28
+ return createHash(algorithm).update(stableStringify(value)).digest(encoding);
29
+ }
30
+ //#endregion
31
+ export { contentHash, stableStringify };
@@ -1,4 +1,11 @@
1
+ import { ISO_DATE_PATTERN } from "../filter/coerce-dates.mjs";
1
2
  //#region src/query-parser/coerce.ts
3
+ /**
4
+ * Scalar coercion. URLs are strings; filters compare against typed fields.
5
+ * The parser uses `fieldTypes` hints when provided, otherwise a safe
6
+ * heuristic that avoids the classic footguns (string SKUs becoming
7
+ * numbers, numeric-looking strings becoming Dates).
8
+ */
2
9
  const BOOLEAN_STRINGS = /* @__PURE__ */ new Set([
3
10
  "true",
4
11
  "1",
@@ -11,7 +18,7 @@ const FALSEY_STRINGS = /* @__PURE__ */ new Set([
11
18
  "no",
12
19
  "off"
13
20
  ]);
14
- const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
21
+ const ISO_DATE_RE = ISO_DATE_PATTERN;
15
22
  /**
16
23
  * Coerce a single URL value to its field-declared type, or to a best-guess
17
24
  * scalar when no hint exists. Always returns `string`, `number`, `boolean`,
package/package.json CHANGED
@@ -1,180 +1,184 @@
1
1
  {
2
- "name": "@classytic/repo-core",
3
- "version": "0.18.0",
4
- "description": "Driver-agnostic repository primitives: hooks, Filter IR, operations, pagination, cache contract. Foundation for mongokit, sqlitekit, pgkit, and prismakit. Lean by design — no plugins ship here; each kit owns its own.",
5
- "type": "module",
6
- "sideEffects": false,
7
- "files": [
8
- "dist",
9
- "README.md",
10
- "LICENSE",
11
- "CHANGELOG.md"
12
- ],
13
- "engines": {
14
- "node": ">=22"
15
- },
16
- "exports": {
17
- "./hooks": {
18
- "types": "./dist/hooks/index.d.mts",
19
- "default": "./dist/hooks/index.mjs"
20
- },
21
- "./operations": {
22
- "types": "./dist/operations/index.d.mts",
23
- "default": "./dist/operations/index.mjs"
24
- },
25
- "./errors": {
26
- "types": "./dist/errors/index.d.mts",
27
- "default": "./dist/errors/index.mjs"
28
- },
29
- "./pagination": {
30
- "types": "./dist/pagination/index.d.mts",
31
- "default": "./dist/pagination/index.mjs"
32
- },
33
- "./repository": {
34
- "types": "./dist/repository/index.d.mts",
35
- "default": "./dist/repository/index.mjs"
36
- },
37
- "./filter": {
38
- "types": "./dist/filter/index.d.mts",
39
- "default": "./dist/filter/index.mjs"
40
- },
41
- "./update": {
42
- "types": "./dist/update/index.d.mts",
43
- "default": "./dist/update/index.mjs"
44
- },
45
- "./query-parser": {
46
- "types": "./dist/query-parser/index.d.mts",
47
- "default": "./dist/query-parser/index.mjs"
48
- },
49
- "./context": {
50
- "types": "./dist/context/index.d.mts",
51
- "default": "./dist/context/index.mjs"
52
- },
53
- "./cache": {
54
- "types": "./dist/cache/index.d.mts",
55
- "default": "./dist/cache/index.mjs"
56
- },
57
- "./events": {
58
- "types": "./dist/events/index.d.mts",
59
- "default": "./dist/events/index.mjs"
60
- },
61
- "./schema": {
62
- "types": "./dist/schema/index.d.mts",
63
- "default": "./dist/schema/index.mjs"
64
- },
65
- "./testing": {
66
- "types": "./dist/testing/index.d.mts",
67
- "default": "./dist/testing/index.mjs"
68
- },
69
- "./tenant": {
70
- "types": "./dist/tenant/index.d.mts",
71
- "default": "./dist/tenant/index.mjs"
72
- },
73
- "./lookup": {
74
- "types": "./dist/lookup/index.d.mts",
75
- "default": "./dist/lookup/index.mjs"
76
- },
77
- "./adapter": {
78
- "types": "./dist/adapter/index.d.mts",
79
- "default": "./dist/adapter/index.mjs"
80
- },
81
- "./better-auth": {
82
- "types": "./dist/better-auth/index.d.mts",
83
- "default": "./dist/better-auth/index.mjs"
84
- },
85
- "./aggregate": {
86
- "types": "./dist/aggregate/index.d.mts",
87
- "default": "./dist/aggregate/index.mjs"
88
- },
89
- "./plugins": {
90
- "types": "./dist/plugins/index.d.mts",
91
- "default": "./dist/plugins/index.mjs"
92
- },
93
- "./lock": {
94
- "types": "./dist/lock/index.d.mts",
95
- "default": "./dist/lock/index.mjs"
96
- },
97
- "./usage": {
98
- "types": "./dist/usage/index.d.mts",
99
- "default": "./dist/usage/index.mjs"
100
- },
101
- "./package.json": "./package.json",
102
- "./sync": {
103
- "types": "./dist/sync/index.d.mts",
104
- "default": "./dist/sync/index.mjs"
105
- },
106
- "./cleanup": {
107
- "types": "./dist/cleanup/index.d.mts",
108
- "default": "./dist/cleanup/index.mjs"
109
- }
110
- },
111
- "keywords": [
112
- "repository",
113
- "repository-pattern",
114
- "data-access",
115
- "hooks",
116
- "filter-ir",
117
- "pagination",
118
- "cursor-pagination",
119
- "keyset-pagination",
120
- "plugin-based",
121
- "driver-agnostic",
122
- "typescript",
123
- "esm"
124
- ],
125
- "author": "Classytic <classytic.dev@gmail.com> (https://github.com/classytic)",
126
- "license": "MIT",
127
- "repository": {
128
- "type": "git",
129
- "url": "git+https://github.com/classytic/repo-core.git"
130
- },
131
- "bugs": {
132
- "url": "https://github.com/classytic/repo-core/issues"
133
- },
134
- "homepage": "https://github.com/classytic/repo-core#readme",
135
- "scripts": {
136
- "build": "tsdown",
137
- "dev": "tsdown --watch",
138
- "test": "vitest run --project unit --project integration",
139
- "test:unit": "vitest run --project unit",
140
- "test:integration": "vitest run --project integration",
141
- "test:e2e": "vitest run --project e2e",
142
- "test:all": "vitest run",
143
- "test:watch": "vitest --project unit --project integration",
144
- "bench": "vitest bench --run --project bench",
145
- "test:coverage": "vitest run --coverage",
146
- "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
147
- "lint": "biome check src tests",
148
- "lint:fix": "biome check src tests --write",
149
- "format": "biome format src tests --write",
150
- "check": "biome ci src tests --diagnostic-level=error",
151
- "knip": "knip",
152
- "push": "classytic-push",
153
- "prepublishOnly": "npm run check && npm run build && npm run typecheck && npm test",
154
- "release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
155
- "release": "npm run push -- main && npm run release:tag && npm publish",
156
- "publish:dry": "npm publish --dry-run --access public",
157
- "publish:npm": "npm publish --access public"
158
- },
159
- "devDependencies": {
160
- "@arethetypeswrong/cli": "^0.18.2",
161
- "@biomejs/biome": "^2.4.12",
162
- "@classytic/dev-tools": "^0.2.0",
163
- "@types/node": "^22.0.0",
164
- "@vitest/coverage-v8": "^4.1.4",
165
- "fast-check": "^4.7.0",
166
- "knip": "^6.3.0",
167
- "publint": "^0.3.18",
168
- "tsdown": "^0.22.5",
169
- "typescript": "^7.0.2",
170
- "vitest": "^4.1.4"
171
- },
172
- "peerDependencies": {
173
- "vitest": "^3.0.0 || ^4.0.0"
174
- },
175
- "peerDependenciesMeta": {
176
- "vitest": {
177
- "optional": true
178
- }
179
- }
2
+ "name": "@classytic/repo-core",
3
+ "version": "0.20.0",
4
+ "description": "Driver-agnostic repository primitives: hooks, Filter IR, operations, pagination, cache contract. Foundation for mongokit, sqlitekit, pgkit, and prismakit. Lean by design — no plugins ship here; each kit owns its own.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "files": [
8
+ "dist",
9
+ "README.md",
10
+ "LICENSE",
11
+ "CHANGELOG.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=22"
15
+ },
16
+ "exports": {
17
+ "./hooks": {
18
+ "types": "./dist/hooks/index.d.mts",
19
+ "default": "./dist/hooks/index.mjs"
20
+ },
21
+ "./operations": {
22
+ "types": "./dist/operations/index.d.mts",
23
+ "default": "./dist/operations/index.mjs"
24
+ },
25
+ "./errors": {
26
+ "types": "./dist/errors/index.d.mts",
27
+ "default": "./dist/errors/index.mjs"
28
+ },
29
+ "./pagination": {
30
+ "types": "./dist/pagination/index.d.mts",
31
+ "default": "./dist/pagination/index.mjs"
32
+ },
33
+ "./repository": {
34
+ "types": "./dist/repository/index.d.mts",
35
+ "default": "./dist/repository/index.mjs"
36
+ },
37
+ "./filter": {
38
+ "types": "./dist/filter/index.d.mts",
39
+ "default": "./dist/filter/index.mjs"
40
+ },
41
+ "./update": {
42
+ "types": "./dist/update/index.d.mts",
43
+ "default": "./dist/update/index.mjs"
44
+ },
45
+ "./query-parser": {
46
+ "types": "./dist/query-parser/index.d.mts",
47
+ "default": "./dist/query-parser/index.mjs"
48
+ },
49
+ "./context": {
50
+ "types": "./dist/context/index.d.mts",
51
+ "default": "./dist/context/index.mjs"
52
+ },
53
+ "./cache": {
54
+ "types": "./dist/cache/index.d.mts",
55
+ "default": "./dist/cache/index.mjs"
56
+ },
57
+ "./hash": {
58
+ "types": "./dist/hash/index.d.mts",
59
+ "default": "./dist/hash/index.mjs"
60
+ },
61
+ "./events": {
62
+ "types": "./dist/events/index.d.mts",
63
+ "default": "./dist/events/index.mjs"
64
+ },
65
+ "./schema": {
66
+ "types": "./dist/schema/index.d.mts",
67
+ "default": "./dist/schema/index.mjs"
68
+ },
69
+ "./testing": {
70
+ "types": "./dist/testing/index.d.mts",
71
+ "default": "./dist/testing/index.mjs"
72
+ },
73
+ "./tenant": {
74
+ "types": "./dist/tenant/index.d.mts",
75
+ "default": "./dist/tenant/index.mjs"
76
+ },
77
+ "./lookup": {
78
+ "types": "./dist/lookup/index.d.mts",
79
+ "default": "./dist/lookup/index.mjs"
80
+ },
81
+ "./adapter": {
82
+ "types": "./dist/adapter/index.d.mts",
83
+ "default": "./dist/adapter/index.mjs"
84
+ },
85
+ "./better-auth": {
86
+ "types": "./dist/better-auth/index.d.mts",
87
+ "default": "./dist/better-auth/index.mjs"
88
+ },
89
+ "./aggregate": {
90
+ "types": "./dist/aggregate/index.d.mts",
91
+ "default": "./dist/aggregate/index.mjs"
92
+ },
93
+ "./plugins": {
94
+ "types": "./dist/plugins/index.d.mts",
95
+ "default": "./dist/plugins/index.mjs"
96
+ },
97
+ "./lock": {
98
+ "types": "./dist/lock/index.d.mts",
99
+ "default": "./dist/lock/index.mjs"
100
+ },
101
+ "./usage": {
102
+ "types": "./dist/usage/index.d.mts",
103
+ "default": "./dist/usage/index.mjs"
104
+ },
105
+ "./package.json": "./package.json",
106
+ "./sync": {
107
+ "types": "./dist/sync/index.d.mts",
108
+ "default": "./dist/sync/index.mjs"
109
+ },
110
+ "./cleanup": {
111
+ "types": "./dist/cleanup/index.d.mts",
112
+ "default": "./dist/cleanup/index.mjs"
113
+ }
114
+ },
115
+ "keywords": [
116
+ "repository",
117
+ "repository-pattern",
118
+ "data-access",
119
+ "hooks",
120
+ "filter-ir",
121
+ "pagination",
122
+ "cursor-pagination",
123
+ "keyset-pagination",
124
+ "plugin-based",
125
+ "driver-agnostic",
126
+ "typescript",
127
+ "esm"
128
+ ],
129
+ "author": "Classytic <classytic.dev@gmail.com> (https://github.com/classytic)",
130
+ "license": "MIT",
131
+ "repository": {
132
+ "type": "git",
133
+ "url": "git+https://github.com/classytic/repo-core.git"
134
+ },
135
+ "bugs": {
136
+ "url": "https://github.com/classytic/repo-core/issues"
137
+ },
138
+ "homepage": "https://github.com/classytic/repo-core#readme",
139
+ "scripts": {
140
+ "build": "tsdown",
141
+ "dev": "tsdown --watch",
142
+ "test": "vitest run --project unit --project integration",
143
+ "test:unit": "vitest run --project unit",
144
+ "test:integration": "vitest run --project integration",
145
+ "test:e2e": "vitest run --project e2e",
146
+ "test:all": "vitest run",
147
+ "test:watch": "vitest --project unit --project integration",
148
+ "bench": "vitest bench --run --project bench",
149
+ "test:coverage": "vitest run --coverage",
150
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
151
+ "lint": "biome check src tests",
152
+ "lint:fix": "biome check src tests --write",
153
+ "format": "biome format src tests --write",
154
+ "check": "biome ci src tests --diagnostic-level=error",
155
+ "knip": "knip",
156
+ "push": "classytic-push",
157
+ "prepublishOnly": "npm run check && npm run build && npm run typecheck && npm test",
158
+ "release:tag": "node -e \"require('child_process').execSync('npm run push -- v'+require('./package.json').version,{stdio:'inherit'})\"",
159
+ "release": "npm run push -- main && npm run release:tag && npm publish",
160
+ "publish:dry": "npm publish --dry-run --access public",
161
+ "publish:npm": "npm publish --access public"
162
+ },
163
+ "devDependencies": {
164
+ "@arethetypeswrong/cli": "^0.18.2",
165
+ "@biomejs/biome": "^2.4.12",
166
+ "@classytic/dev-tools": "^0.2.0",
167
+ "@types/node": "^22.0.0",
168
+ "@vitest/coverage-v8": "^4.1.4",
169
+ "fast-check": "^4.7.0",
170
+ "knip": "^6.3.0",
171
+ "publint": "^0.3.18",
172
+ "tsdown": "^0.22.5",
173
+ "typescript": "^7.0.2",
174
+ "vitest": "^4.1.4"
175
+ },
176
+ "peerDependencies": {
177
+ "vitest": "^3.0.0 || ^4.0.0"
178
+ },
179
+ "peerDependenciesMeta": {
180
+ "vitest": {
181
+ "optional": true
182
+ }
183
+ }
180
184
  }