@classytic/repo-core 0.19.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,19 @@ 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
+
7
20
  ## [0.19.0] - 2026-07-29
8
21
 
9
22
  ### Added — `coerceFilterDates` + ISO date helpers (`./filter`)
@@ -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 };
@@ -60,6 +60,28 @@ declare function tryCoerceIsoDate(value: unknown): unknown;
60
60
  * // → { $and: [{ createdAt: { $gte: Date(2026-04-01) } }] }
61
61
  * ```
62
62
  */
63
- declare function coerceFilterDates(filter: Record<string, unknown>): Record<string, unknown>;
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>;
64
86
  //#endregion
65
- export { ISO_DATE_PATTERN, coerceFilterDates, tryCoerceIsoDate };
87
+ export { CoerceFilterDatesOptions, ISO_DATE_PATTERN, coerceFilterDates, tryCoerceIsoDate };
@@ -83,33 +83,22 @@ const LOGICAL_OBJECT_OPS = /* @__PURE__ */ new Set(["$not", "not"]);
83
83
  function isPlainObject(value) {
84
84
  return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
85
85
  }
86
- /**
87
- * Walk a record-shape filter and coerce ISO-date strings on range operators
88
- * to `Date`, recursing through logical wrappers. Returns a new object;
89
- * the input is never mutated. Non-range operators, real nested documents,
90
- * and already-typed values pass through unchanged.
91
- *
92
- * @example
93
- * ```ts
94
- * coerceFilterDates({ $and: [{ createdAt: { $gte: '2026-04-01' } }] })
95
- * // → { $and: [{ createdAt: { $gte: Date(2026-04-01) } }] }
96
- * ```
97
- */
98
- function coerceFilterDates(filter) {
86
+ function coerceFilterDates(filter, options) {
99
87
  const out = {};
100
88
  for (const [key, value] of Object.entries(filter)) {
101
89
  if (LOGICAL_ARRAY_OPS.has(key) && Array.isArray(value)) {
102
- out[key] = value.map((entry) => isPlainObject(entry) ? coerceFilterDates(entry) : entry);
90
+ out[key] = value.map((entry) => isPlainObject(entry) ? coerceFilterDates(entry, options) : entry);
103
91
  continue;
104
92
  }
105
93
  if (LOGICAL_OBJECT_OPS.has(key) && isPlainObject(value)) {
106
- out[key] = coerceFilterDates(value);
94
+ out[key] = coerceFilterDates(value, options);
107
95
  continue;
108
96
  }
109
97
  if (isPlainObject(value)) {
110
98
  let changed = false;
111
99
  const coerced = {};
112
- for (const [op, operand] of Object.entries(value)) if (RANGE_OPS.has(op)) {
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)) {
113
102
  const next = tryCoerceIsoDate(operand);
114
103
  coerced[op] = next;
115
104
  if (next !== operand) changed = true;
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/repo-core",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
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
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -54,6 +54,10 @@
54
54
  "types": "./dist/cache/index.d.mts",
55
55
  "default": "./dist/cache/index.mjs"
56
56
  },
57
+ "./hash": {
58
+ "types": "./dist/hash/index.d.mts",
59
+ "default": "./dist/hash/index.mjs"
60
+ },
57
61
  "./events": {
58
62
  "types": "./dist/events/index.d.mts",
59
63
  "default": "./dist/events/index.mjs"