@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 +13 -0
- package/dist/better-auth/index.d.mts +12 -0
- package/dist/better-auth/index.mjs +8 -1
- package/dist/cache/stable-stringify.d.mts +15 -0
- package/dist/filter/coerce-dates.d.mts +24 -2
- package/dist/filter/coerce-dates.mjs +5 -16
- package/dist/hash/index.d.mts +14 -0
- package/dist/hash/index.mjs +31 -0
- package/package.json +5 -1
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|