@classytic/repo-core 0.20.0 → 0.21.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,18 @@ 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.21.0] - 2026-08-10
8
+
9
+ ### Added — LRU-bounded `createMemoryCacheAdapter`
10
+
11
+ - `createMemoryCacheAdapter(options?)` now accepts `MemoryCacheAdapterOptions` with a `maxEntries` cap (default `10,000`). Past the cap the least-recently-used entry is evicted — read promotion (delete + re-insert) keeps the map ordered by recency so eviction is O(1). A TTL alone does not bound the map: entries only expire when read, so a high-cardinality keyspace (per-tenant, per-commit, per-filter keys) grows monotonically until the process dies. Eviction is always safe for a cache, so the bound is on by default. Set `maxEntries: 0` to restore the previous unbounded behaviour.
12
+ - `MemoryCacheAdapterOptions` exported from `@classytic/repo-core/cache`.
13
+
14
+ ### Added — timezone-aware date buckets (`AggDateBucket.timezone`)
15
+
16
+ - `AggDateBucket.timezone` — IANA zone the bucket boundaries are drawn in; absent means UTC. A UTC day is not a business day in any non-UTC deployment: rows from 18:00 to midnight local fall in the previous UTC day, silently reporting the wrong period in daily/monthly rollups.
17
+ - `AggregateOpsSupport.dateBucketTimezone` capability flag — a kit that cannot draw DST-correct boundaries (e.g. SQLite, no tz database) MUST declare `dateBucketTimezone: false` and throw when the field is set rather than silently bucketing in UTC. Mongokit (`$dateTrunc` / `$dateToString` both accept `timezone`) declares `true`.
18
+
7
19
  ## [0.20.0] - 2026-08-04
8
20
 
9
21
  ### Added
@@ -62,7 +62,8 @@ var CacheEngine = class {
62
62
  status: "bypass",
63
63
  data: void 0
64
64
  };
65
- const inspection = inspectEnvelope(await this.adapter.get(key));
65
+ const raw = await this.adapter.get(key);
66
+ const inspection = inspectEnvelope(raw);
66
67
  if (inspection.state === "missing" || inspection.state === "expired") return {
67
68
  status: "miss",
68
69
  data: void 0
@@ -1,9 +1,9 @@
1
1
  import { CacheOptions, CacheReadResult } from "./options.mjs";
2
2
  import { CacheAdapter } from "./types.mjs";
3
3
  import { CacheEngine, CacheEngineOptions, SingleFlightClaim } from "./engine.mjs";
4
- import { createMemoryCacheAdapter } from "./memory-adapter.mjs";
4
+ import { MemoryCacheAdapterOptions, createMemoryCacheAdapter } from "./memory-adapter.mjs";
5
5
  import { DEFAULT_SHAPE_KEYS_BY_OP } from "./plugin/context.mjs";
6
6
  import { DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, LogCallbacks, RepositoryCacheHandle, RepositoryCachePluginOptions, cachePlugin } from "./plugin/index.mjs";
7
7
  import { scheduleBackground } from "./runtime.mjs";
8
8
  import { CacheTimeoutError, TimeoutAdapterOptions, withTimeout } from "./timeout-adapter.mjs";
9
- export { type CacheAdapter, CacheEngine, type CacheEngineOptions, type CacheOptions, type CacheReadResult, CacheTimeoutError, DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, DEFAULT_SHAPE_KEYS_BY_OP, type LogCallbacks, type RepositoryCacheHandle, type RepositoryCachePluginOptions, type SingleFlightClaim, type TimeoutAdapterOptions, cachePlugin, createMemoryCacheAdapter, scheduleBackground, withTimeout };
9
+ export { type CacheAdapter, CacheEngine, type CacheEngineOptions, type CacheOptions, type CacheReadResult, CacheTimeoutError, DEFAULT_CACHEABLE_OPS, DEFAULT_INVALIDATING_OPS, DEFAULT_SHAPE_KEYS_BY_OP, type LogCallbacks, type MemoryCacheAdapterOptions, type RepositoryCacheHandle, type RepositoryCachePluginOptions, type SingleFlightClaim, type TimeoutAdapterOptions, cachePlugin, createMemoryCacheAdapter, scheduleBackground, withTimeout };
@@ -1,6 +1,24 @@
1
1
  import { CacheAdapter } from "./types.mjs";
2
2
  //#region src/cache/memory-adapter.d.ts
3
- /** Minimal in-memory `Map`-backed adapter with per-key TTL + prefix invalidation. */
4
- declare function createMemoryCacheAdapter(): CacheAdapter;
3
+ interface MemoryCacheAdapterOptions {
4
+ /**
5
+ * Hard entry ceiling; the least-recently-used entry is evicted past it.
6
+ * Default 10,000.
7
+ *
8
+ * A TTL alone does not bound a cache. Entries only expire when someone reads
9
+ * them, and a workload with high key cardinality (per-tenant keys, per-commit
10
+ * keys, per-filter query keys) mints faster than it re-reads, so the map grows
11
+ * monotonically until the process dies. Eviction is always semantically safe
12
+ * for a cache — a miss is a correct answer — so this is on by default rather
13
+ * than opt-in.
14
+ *
15
+ * Set `0` for the previous unbounded behaviour. Only do that if something else
16
+ * in your process bounds the keyspace.
17
+ */
18
+ maxEntries?: number;
19
+ }
20
+ /** Minimal in-memory `Map`-backed adapter with per-key TTL, LRU eviction and
21
+ * prefix invalidation. */
22
+ declare function createMemoryCacheAdapter(options?: MemoryCacheAdapterOptions): CacheAdapter;
5
23
  //#endregion
6
- export { createMemoryCacheAdapter };
24
+ export { MemoryCacheAdapterOptions, createMemoryCacheAdapter };
@@ -1,8 +1,20 @@
1
1
  //#region src/cache/memory-adapter.ts
2
- /** Minimal in-memory `Map`-backed adapter with per-key TTL + prefix invalidation. */
3
- function createMemoryCacheAdapter() {
2
+ /** Minimal in-memory `Map`-backed adapter with per-key TTL, LRU eviction and
3
+ * prefix invalidation. */
4
+ function createMemoryCacheAdapter(options = {}) {
5
+ const maxEntries = options.maxEntries ?? 1e4;
4
6
  const store = /* @__PURE__ */ new Map();
5
7
  const now = () => Date.now();
8
+ /** Map iteration order is insertion order, so re-inserting on read makes the
9
+ * first key the least-recently-used one. */
10
+ function evictIfNeeded() {
11
+ if (maxEntries <= 0) return;
12
+ while (store.size > maxEntries) {
13
+ const oldest = store.keys().next().value;
14
+ if (oldest === void 0) return;
15
+ store.delete(oldest);
16
+ }
17
+ }
6
18
  function readUnexpired(key) {
7
19
  const entry = store.get(key);
8
20
  if (!entry) return void 0;
@@ -10,6 +22,8 @@ function createMemoryCacheAdapter() {
10
22
  store.delete(key);
11
23
  return;
12
24
  }
25
+ store.delete(key);
26
+ store.set(key, entry);
13
27
  return entry;
14
28
  }
15
29
  return {
@@ -20,10 +34,12 @@ function createMemoryCacheAdapter() {
20
34
  },
21
35
  set(key, value, ttlSeconds = 60) {
22
36
  const expiresAt = ttlSeconds === 0 ? 0 : now() + ttlSeconds * 1e3;
37
+ store.delete(key);
23
38
  store.set(key, {
24
39
  value,
25
40
  expiresAt
26
41
  });
42
+ evictIfNeeded();
27
43
  },
28
44
  delete(key) {
29
45
  store.delete(key);
@@ -54,6 +70,7 @@ function createMemoryCacheAdapter() {
54
70
  value: set,
55
71
  expiresAt
56
72
  });
73
+ evictIfNeeded();
57
74
  }
58
75
  let added = 0;
59
76
  for (const m of members) if (!set.has(m)) {
@@ -76,6 +93,7 @@ function createMemoryCacheAdapter() {
76
93
  value: next,
77
94
  expiresAt
78
95
  });
96
+ evictIfNeeded();
79
97
  return next;
80
98
  }
81
99
  };
@@ -21,7 +21,8 @@ function registerReadHooks(repo, op, engine, hookCtx) {
21
21
  function registerBefore(op, engine, hookCtx) {
22
22
  return async (rawContext) => {
23
23
  const context = ctx(rawContext);
24
- const resolved = resolveCacheOptions(extractCallCacheOptions(context, op), hookCtx.perOpDefaults, hookCtx.defaults);
24
+ const callOpts = extractCallCacheOptions(context, op);
25
+ const resolved = resolveCacheOptions(callOpts, hookCtx.perOpDefaults, hookCtx.defaults);
25
26
  if (!resolved.enabled) return;
26
27
  const scopeTags = hookCtx.autoTagsFromScope ? extractScopeTags(context) : [];
27
28
  const allTags = mergeTags(resolved.tags, scopeTags);
@@ -42,7 +42,7 @@ import { tagIndexKey } from "./keys.mjs";
42
42
  */
43
43
  async function appendKeyToTags(adapter, prefix, cacheKey, tags, ttlSeconds) {
44
44
  if (tags.length === 0) return;
45
- const indexTtl = Math.min(Math.max(ttlSeconds, 60), 1440 * 60);
45
+ const indexTtl = Math.min(Math.max(ttlSeconds, 60), 86400);
46
46
  if (adapter.addToSet) {
47
47
  const fn = adapter.addToSet.bind(adapter);
48
48
  await Promise.all(tags.map((tag) => fn(tagIndexKey(prefix, tag), [cacheKey], indexTtl)));
@@ -23,7 +23,7 @@ import { versionKey } from "./keys.mjs";
23
23
  * advance the counter (atomic path is naturally monotonic via
24
24
  * adapter.increment).
25
25
  */
26
- const VERSION_TTL_SECONDS = 1440 * 60;
26
+ const VERSION_TTL_SECONDS = 86400;
27
27
  /**
28
28
  * Read the current version for a `model` (optionally per-scope). Returns
29
29
  * `0` when no version has been set yet — the initial value before any
@@ -46,7 +46,8 @@ const conservativeMongoIsDuplicateKey = (err) => {
46
46
  function toDuplicateKeyHttpError(meta, options = {}) {
47
47
  const exposed = options.exposeValues === true;
48
48
  const valuesString = exposed && meta.values ? Object.entries(meta.values).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join(", ") : "";
49
- const httpError = createError(409, meta.fields.length ? `Duplicate value for ${meta.fields.join(", ")}${valuesString ? ` (${valuesString})` : ""}` : "Duplicate key error");
49
+ const detail = meta.fields.length ? `Duplicate value for ${meta.fields.join(", ")}${valuesString ? ` (${valuesString})` : ""}` : "Duplicate key error";
50
+ const httpError = createError(409, detail);
50
51
  httpError.duplicate = {
51
52
  fields: meta.fields,
52
53
  ...exposed && meta.values ? { values: { ...meta.values } } : {}
@@ -104,9 +104,7 @@ function leafFromRecord(field, value) {
104
104
  case "endsWith":
105
105
  ops.push(endsWith(field, v));
106
106
  break;
107
- case "exists":
108
- ops.push(v ? isNotNull(field) : isNull(field));
109
- break;
107
+ case "exists": ops.push(v ? isNotNull(field) : isNull(field));
110
108
  }
111
109
  if (ops.length === 0) return TRUE;
112
110
  if (ops.length === 1) return ops[0];
@@ -180,7 +180,7 @@ function getOrCompileRegex(pattern, flags) {
180
180
  * filters never regex-test megabyte fields. (Trusted-source patterns make
181
181
  * pattern-side ReDoS a non-issue; this caps the input side.)
182
182
  */
183
- const MAX_REGEX_INPUT = 64 * 1024;
183
+ const MAX_REGEX_INPUT = 65536;
184
184
  /** Guarded regex test: string-only, input-length-capped (see MAX_REGEX_INPUT). */
185
185
  function regexTest(re, v) {
186
186
  return typeof v === "string" && v.length <= MAX_REGEX_INPUT && re.test(v);
@@ -52,6 +52,15 @@ interface AggregateOpsSupport {
52
52
  * equivalents.
53
53
  */
54
54
  topN?: boolean;
55
+ /**
56
+ * `AggDateBucket.timezone` — bucket boundaries drawn in an IANA zone
57
+ * rather than UTC. Mongokit supports it natively (`$dateTrunc` /
58
+ * `$dateToString` both take `timezone`). SQLite has no tz database, so
59
+ * sqlitekit cannot draw DST-correct boundaries and MUST THROW rather
60
+ * than silently bucketing in UTC — a wrong-period number that looks
61
+ * right is worse than a refusal.
62
+ */
63
+ dateBucketTimezone?: boolean;
55
64
  /**
56
65
  * `dateBuckets: { ..., interval: { every, unit } }` custom-bin
57
66
  * form. Kits that only support named-bucket form can leave this
@@ -919,6 +919,23 @@ interface AggDateBucket {
919
919
  field: string;
920
920
  /** Bucket granularity. */
921
921
  interval: AggDateBucketInterval;
922
+ /**
923
+ * IANA zone the bucket boundaries are drawn in. Absent means UTC.
924
+ *
925
+ * A UTC day is not a BUSINESS day. In a UTC+6 deployment every row from
926
+ * 18:00 to midnight local falls in the PREVIOUS UTC day, and at month-end in
927
+ * the previous month — so a daily or monthly rollup silently reports the
928
+ * wrong period. Nothing throws; the chart simply draws the wrong day. The
929
+ * absence of this field is why every business-day rollup had to abandon the
930
+ * portable IR and hand-roll a kit-native pipeline, and hand-rolling made the
931
+ * zone optional again.
932
+ *
933
+ * REQUIRES the `dateBucketTimezone` capability. A kit that cannot draw
934
+ * DST-correct boundaries (SQLite has no tz database) MUST THROW when this is
935
+ * set — never fall back to UTC. Falling back returns a plausible number for
936
+ * the wrong period, which is the failure this field exists to prevent.
937
+ */
938
+ timezone?: string;
922
939
  }
923
940
  /**
924
941
  * Portable aggregation request. Compiles to SQL (`SELECT ... FROM ...
@@ -69,22 +69,26 @@ function runStandardRepoConformance(harness) {
69
69
  expect(fetched?.createdAt).toBe(isoAt(0));
70
70
  });
71
71
  it("getById miss returns null", async () => {
72
- expect(await ctx.repo.getById("does-not-exist-xyz")).toBeNull();
72
+ const result = await ctx.repo.getById("does-not-exist-xyz");
73
+ expect(result).toBeNull();
73
74
  });
74
75
  it("update by id returns updated doc; miss returns null", async () => {
75
76
  const id = idOf(await ctx.repo.create(harness.makeDoc({
76
77
  name: "Bob",
77
78
  count: 1
78
79
  })), harness.idField);
79
- expect((await ctx.repo.update(id, { count: 99 }))?.count).toBe(99);
80
- expect(await ctx.repo.update("no-such-id", { count: 1 })).toBeNull();
80
+ const updated = await ctx.repo.update(id, { count: 99 });
81
+ expect(updated?.count).toBe(99);
82
+ const miss = await ctx.repo.update("no-such-id", { count: 1 });
83
+ expect(miss).toBeNull();
81
84
  });
82
85
  it("delete by id succeeds; second delete returns null (miss)", async () => {
83
86
  const id = idOf(await ctx.repo.create(harness.makeDoc({ name: "Carol" })), harness.idField);
84
87
  const first = await ctx.repo.delete(id);
85
88
  expect(first).not.toBeNull();
86
89
  expect(first?.message).toBeDefined();
87
- expect(await ctx.repo.delete(id)).toBeNull();
90
+ const second = await ctx.repo.delete(id);
91
+ expect(second).toBeNull();
88
92
  });
89
93
  });
90
94
  describe("findOneAndUpdate", () => {
@@ -118,11 +122,13 @@ function runStandardRepoConformance(harness) {
118
122
  });
119
123
  it("returnDocument: \"before\" returns pre-update state", async () => {
120
124
  if (!ctx.repo.findOneAndUpdate) return;
121
- expect((await ctx.repo.findOneAndUpdate({ name: "u2" }, { category: "archived" }, { returnDocument: "before" }))?.category).toBe("reader");
125
+ const before = await ctx.repo.findOneAndUpdate({ name: "u2" }, { category: "archived" }, { returnDocument: "before" });
126
+ expect(before?.category).toBe("reader");
122
127
  });
123
128
  it("no match, no upsert → returns null", async () => {
124
129
  if (!ctx.repo.findOneAndUpdate) return;
125
- expect(await ctx.repo.findOneAndUpdate({ name: "does-not-exist" }, { category: "x" })).toBeNull();
130
+ const result = await ctx.repo.findOneAndUpdate({ name: "does-not-exist" }, { category: "x" });
131
+ expect(result).toBeNull();
126
132
  });
127
133
  it.skipIf(!harness.features.upsert)("upsert inserts when no row matches", async () => {
128
134
  if (!ctx.repo.findOneAndUpdate) return;
@@ -161,7 +167,8 @@ function runStandardRepoConformance(harness) {
161
167
  const result = await ctx.repo.updateMany({ category: "reader" }, { category: "former-reader" });
162
168
  expect(result.matchedCount).toBe(2);
163
169
  expect(result.modifiedCount).toBe(2);
164
- expect(await ctx.repo.findAll({ category: "admin" })).toHaveLength(1);
170
+ const admins = await ctx.repo.findAll({ category: "admin" });
171
+ expect(admins).toHaveLength(1);
165
172
  });
166
173
  it("updateMany with no match returns matchedCount 0", async () => {
167
174
  if (!ctx.repo.updateMany) return;
@@ -171,12 +178,15 @@ function runStandardRepoConformance(harness) {
171
178
  });
172
179
  it("deleteMany removes matching rows and reports count", async () => {
173
180
  if (!ctx.repo.deleteMany) return;
174
- expect((await ctx.repo.deleteMany({ category: "reader" }, { mode: "hard" })).deletedCount).toBe(2);
175
- expect(await ctx.repo.findAll()).toHaveLength(1);
181
+ const result = await ctx.repo.deleteMany({ category: "reader" }, { mode: "hard" });
182
+ expect(result.deletedCount).toBe(2);
183
+ const remaining = await ctx.repo.findAll();
184
+ expect(remaining).toHaveLength(1);
176
185
  });
177
186
  it("deleteMany with empty match returns deletedCount 0", async () => {
178
187
  if (!ctx.repo.deleteMany) return;
179
- expect((await ctx.repo.deleteMany({ category: "nope" }, { mode: "hard" })).deletedCount).toBe(0);
188
+ const result = await ctx.repo.deleteMany({ category: "nope" }, { mode: "hard" });
189
+ expect(result.deletedCount).toBe(0);
180
190
  });
181
191
  });
182
192
  describe("projections", () => {
@@ -215,8 +225,10 @@ function runStandardRepoConformance(harness) {
215
225
  });
216
226
  it.skipIf(!harness.features.countAndExists)("count with filter matches expected rows", async () => {
217
227
  if (!ctx.repo.count) return;
218
- expect(await ctx.repo.count({ category: "reader" })).toBe(2);
219
- expect(await ctx.repo.count({ category: "no-such" })).toBe(0);
228
+ const readers = await ctx.repo.count({ category: "reader" });
229
+ expect(readers).toBe(2);
230
+ const none = await ctx.repo.count({ category: "no-such" });
231
+ expect(none).toBe(0);
220
232
  });
221
233
  it.skipIf(!harness.features.countAndExists)("exists is truthy when filter matches, falsy when it does not", async () => {
222
234
  if (!ctx.repo.exists) return;
@@ -257,14 +269,15 @@ function runStandardRepoConformance(harness) {
257
269
  });
258
270
  it.skipIf(skipNoAgg)("empty result set returns { rows: [] } (no throw)", async () => {
259
271
  if (!ctx.repo.aggregate) return;
260
- expect((await ctx.repo.aggregate({
272
+ const result = await ctx.repo.aggregate({
261
273
  filter: { category: "does-not-exist" },
262
274
  groupBy: "category",
263
275
  measures: { total: {
264
276
  op: "sum",
265
277
  field: "count"
266
278
  } }
267
- })).rows).toEqual([]);
279
+ });
280
+ expect(result.rows).toEqual([]);
268
281
  });
269
282
  it.skipIf(skipNoAgg)("groupBy + sum produces one row per group with correct totals", async () => {
270
283
  if (!ctx.repo.aggregate) return;
@@ -310,7 +323,7 @@ function runStandardRepoConformance(harness) {
310
323
  });
311
324
  it.skipIf(skipNoAgg)("filtered sum/count: KPI tiles side-by-side", async () => {
312
325
  if (!ctx.repo.aggregate) return;
313
- expect((await ctx.repo.aggregate({
326
+ const result = await ctx.repo.aggregate({
314
327
  groupBy: "category",
315
328
  measures: {
316
329
  activeCount: {
@@ -332,7 +345,8 @@ function runStandardRepoConformance(harness) {
332
345
  }
333
346
  },
334
347
  sort: { category: 1 }
335
- })).rows).toEqual([{
348
+ });
349
+ expect(result.rows).toEqual([{
336
350
  category: "admin",
337
351
  activeCount: 1,
338
352
  inactiveCount: 1,
@@ -360,7 +374,7 @@ function runStandardRepoConformance(harness) {
360
374
  });
361
375
  it.skipIf(skipNoTopN)("top-N: keep top 1 per category by count", async () => {
362
376
  if (!ctx.repo.aggregate) return;
363
- expect((await ctx.repo.aggregate({
377
+ const result = await ctx.repo.aggregate({
364
378
  groupBy: ["category", "name"],
365
379
  measures: { n: {
366
380
  op: "sum",
@@ -372,7 +386,8 @@ function runStandardRepoConformance(harness) {
372
386
  limit: 1
373
387
  },
374
388
  sort: { category: 1 }
375
- })).rows).toEqual([{
389
+ });
390
+ expect(result.rows).toEqual([{
376
391
  category: "admin",
377
392
  name: "d",
378
393
  n: 40
@@ -384,7 +399,7 @@ function runStandardRepoConformance(harness) {
384
399
  });
385
400
  it.skipIf(skipNoTopN)("top-N: row_number ties strategy yields exactly N rows per partition", async () => {
386
401
  if (!ctx.repo.aggregate) return;
387
- expect((await ctx.repo.aggregate({
402
+ const result = await ctx.repo.aggregate({
388
403
  groupBy: ["category", "name"],
389
404
  measures: { n: { op: "count" } },
390
405
  topN: {
@@ -394,7 +409,8 @@ function runStandardRepoConformance(harness) {
394
409
  ties: "row_number"
395
410
  },
396
411
  sort: { category: 1 }
397
- })).rows.map((r) => `${r.category}:${r.name}`)).toEqual(["admin:c", "reader:a"]);
412
+ });
413
+ expect(result.rows.map((r) => `${r.category}:${r.name}`)).toEqual(["admin:c", "reader:a"]);
398
414
  });
399
415
  it.skipIf(skipNoTopN)("top-N: throws on partitionBy referencing an unknown column", async () => {
400
416
  if (!ctx.repo.aggregate) return;
@@ -614,7 +630,8 @@ function runStandardRepoConformance(harness) {
614
630
  });
615
631
  const handle = cachedRepo.cache;
616
632
  if (!handle) return;
617
- expect(await handle.invalidateByTags(["inv-tag"])).toBeGreaterThanOrEqual(1);
633
+ const cleared = await handle.invalidateByTags(["inv-tag"]);
634
+ expect(cleared).toBeGreaterThanOrEqual(1);
618
635
  });
619
636
  });
620
637
  describe("aggregate — date buckets + keyset", () => {
@@ -676,7 +693,7 @@ function runStandardRepoConformance(harness) {
676
693
  });
677
694
  it.skipIf(skipNoAgg)("date bucket month groups rows under YYYY-MM labels", async () => {
678
695
  if (!ctx.repo.aggregate) return;
679
- expect((await ctx.repo.aggregate({
696
+ const result = await ctx.repo.aggregate({
680
697
  filter: { category: "bk-paid" },
681
698
  dateBuckets: { month: {
682
699
  field: "createdAt",
@@ -687,7 +704,8 @@ function runStandardRepoConformance(harness) {
687
704
  field: "count"
688
705
  } },
689
706
  sort: { month: 1 }
690
- })).rows).toEqual([
707
+ });
708
+ expect(result.rows).toEqual([
691
709
  {
692
710
  month: "2026-01",
693
711
  revenue: 300
@@ -708,7 +726,7 @@ function runStandardRepoConformance(harness) {
708
726
  });
709
727
  it.skipIf(skipNoAgg)("date bucket day emits YYYY-MM-DD", async () => {
710
728
  if (!ctx.repo.aggregate) return;
711
- expect((await ctx.repo.aggregate({
729
+ const result = await ctx.repo.aggregate({
712
730
  filter: { category: "bk-paid" },
713
731
  dateBuckets: { day: {
714
732
  field: "createdAt",
@@ -716,7 +734,8 @@ function runStandardRepoConformance(harness) {
716
734
  } },
717
735
  measures: { n: { op: "count" } },
718
736
  sort: { day: 1 }
719
- })).rows.map((r) => r.day)).toEqual([
737
+ });
738
+ expect(result.rows.map((r) => r.day)).toEqual([
720
739
  "2026-01-15",
721
740
  "2026-01-22",
722
741
  "2026-02-05",
@@ -726,7 +745,7 @@ function runStandardRepoConformance(harness) {
726
745
  });
727
746
  it.skipIf(skipNoAgg)("date bucket quarter emits YYYY-Qn", async () => {
728
747
  if (!ctx.repo.aggregate) return;
729
- expect((await ctx.repo.aggregate({
748
+ const result = await ctx.repo.aggregate({
730
749
  filter: { category: "bk-paid" },
731
750
  dateBuckets: { q: {
732
751
  field: "createdAt",
@@ -734,7 +753,8 @@ function runStandardRepoConformance(harness) {
734
753
  } },
735
754
  measures: { n: { op: "count" } },
736
755
  sort: { q: 1 }
737
- })).rows).toEqual([
756
+ });
757
+ expect(result.rows).toEqual([
738
758
  {
739
759
  q: "2026-Q1",
740
760
  n: 3
@@ -751,14 +771,15 @@ function runStandardRepoConformance(harness) {
751
771
  });
752
772
  it.skipIf(skipNoAgg)("date bucket year emits YYYY", async () => {
753
773
  if (!ctx.repo.aggregate) return;
754
- expect((await ctx.repo.aggregate({
774
+ const result = await ctx.repo.aggregate({
755
775
  filter: { category: "bk-paid" },
756
776
  dateBuckets: { year: {
757
777
  field: "createdAt",
758
778
  interval: "year"
759
779
  } },
760
780
  measures: { n: { op: "count" } }
761
- })).rows).toEqual([{
781
+ });
782
+ expect(result.rows).toEqual([{
762
783
  year: "2026",
763
784
  n: 5
764
785
  }]);
@@ -797,7 +818,7 @@ function runStandardRepoConformance(harness) {
797
818
  createdAt: "2026-04-15T10:30:00Z"
798
819
  })
799
820
  ]);
800
- expect((await ctx.repo.aggregate({
821
+ const result = await ctx.repo.aggregate({
801
822
  filter: eq("category", "bk2"),
802
823
  dateBuckets: { bin: {
803
824
  field: "createdAt",
@@ -808,7 +829,8 @@ function runStandardRepoConformance(harness) {
808
829
  } },
809
830
  measures: { n: { op: "count" } },
810
831
  sort: { bin: 1 }
811
- })).rows).toEqual([
832
+ });
833
+ expect(result.rows).toEqual([
812
834
  {
813
835
  bin: "2026-04-15T10:00",
814
836
  n: 2
@@ -845,7 +867,7 @@ function runStandardRepoConformance(harness) {
845
867
  createdAt: "2026-04-15T11:05:00Z"
846
868
  })
847
869
  ]);
848
- expect((await ctx.repo.aggregate({
870
+ const result = await ctx.repo.aggregate({
849
871
  filter: eq("category", "bk3"),
850
872
  dateBuckets: { hour: {
851
873
  field: "createdAt",
@@ -853,7 +875,8 @@ function runStandardRepoConformance(harness) {
853
875
  } },
854
876
  measures: { n: { op: "count" } },
855
877
  sort: { hour: 1 }
856
- })).rows).toEqual([{
878
+ });
879
+ expect(result.rows).toEqual([{
857
880
  hour: "2026-04-15T10:00",
858
881
  n: 2
859
882
  }, {
@@ -863,7 +886,7 @@ function runStandardRepoConformance(harness) {
863
886
  });
864
887
  it.skipIf(skipNoAgg)("date bucket combines with groupBy column", async () => {
865
888
  if (!ctx.repo.aggregate) return;
866
- expect((await ctx.repo.aggregate({
889
+ const result = await ctx.repo.aggregate({
867
890
  filter: in_("category", [...BUCKET_CATS]),
868
891
  dateBuckets: { month: {
869
892
  field: "createdAt",
@@ -875,7 +898,8 @@ function runStandardRepoConformance(harness) {
875
898
  month: 1,
876
899
  category: 1
877
900
  }
878
- })).rows).toEqual([
901
+ });
902
+ expect(result.rows).toEqual([
879
903
  {
880
904
  month: "2026-01",
881
905
  category: "bk-paid",
@@ -1016,10 +1040,12 @@ function runStandardRepoConformance(harness) {
1016
1040
  ]);
1017
1041
  });
1018
1042
  it("in_([]) matches nothing (not everything)", async () => {
1019
- expect(await ctx.repo.findAll(in_("category", []))).toHaveLength(0);
1043
+ const rows = await ctx.repo.findAll(in_("category", []));
1044
+ expect(rows).toHaveLength(0);
1020
1045
  });
1021
1046
  it("in_ with non-empty list matches those values", async () => {
1022
- expect(await ctx.repo.findAll(in_("category", ["a"]))).toHaveLength(2);
1047
+ const rows = await ctx.repo.findAll(in_("category", ["a"]));
1048
+ expect(rows).toHaveLength(2);
1023
1049
  });
1024
1050
  it("eq null matches rows where the field is null", async () => {
1025
1051
  const rows = await ctx.repo.findAll(isNull("category"));
@@ -1027,16 +1053,20 @@ function runStandardRepoConformance(harness) {
1027
1053
  expect(rows[0]?.name).toBe("nullnote");
1028
1054
  });
1029
1055
  it("ne does not include null-valued rows (SQL 3VL / Mongo parity)", async () => {
1030
- expect((await ctx.repo.findAll(ne("category", "a"))).map((r) => r.name).sort()).toEqual(["back", "under"]);
1056
+ const names = (await ctx.repo.findAll(ne("category", "a"))).map((r) => r.name).sort();
1057
+ expect(names).toEqual(["back", "under"]);
1031
1058
  });
1032
1059
  it("like with % metacharacter in the value matches literally", async () => {
1033
- expect((await ctx.repo.findAll(like("notes", "50\\% off"))).map((r) => r.name)).toEqual(["pct"]);
1060
+ const rows = await ctx.repo.findAll(like("notes", "50\\% off"));
1061
+ expect(rows.map((r) => r.name)).toEqual(["pct"]);
1034
1062
  });
1035
1063
  it("like with _ metacharacter in the value matches literally", async () => {
1036
- expect((await ctx.repo.findAll(like("notes", "file\\_name.txt"))).map((r) => r.name)).toEqual(["under"]);
1064
+ const rows = await ctx.repo.findAll(like("notes", "file\\_name.txt"));
1065
+ expect(rows.map((r) => r.name)).toEqual(["under"]);
1037
1066
  });
1038
1067
  it("nested and/or composes correctly", async () => {
1039
- expect((await ctx.repo.findAll(or(and(eq("category", "a"), gt("count", 1)), eq("name", "back")))).map((r) => r.name).sort()).toEqual(["back", "pct"]);
1068
+ const names = (await ctx.repo.findAll(or(and(eq("category", "a"), gt("count", 1)), eq("name", "back")))).map((r) => r.name).sort();
1069
+ expect(names).toEqual(["back", "pct"]);
1040
1070
  });
1041
1071
  });
1042
1072
  describe.skipIf(!harness.features.duplicateKeyError)("isDuplicateKeyError", () => {
@@ -1073,7 +1103,8 @@ function runStandardRepoConformance(harness) {
1073
1103
  }));
1074
1104
  expect(result.doc.name).toBe("Fresh");
1075
1105
  expect(result.created).toBe(true);
1076
- expect(await ctx.repo.findAll()).toHaveLength(1);
1106
+ const all = await ctx.repo.findAll();
1107
+ expect(all).toHaveLength(1);
1077
1108
  });
1078
1109
  it("returns existing row when filter matches (created: false, no insert)", async () => {
1079
1110
  if (!ctx.repo.getOrCreate) return;
@@ -1087,7 +1118,8 @@ function runStandardRepoConformance(harness) {
1087
1118
  }));
1088
1119
  expect(result.doc.name).toBe("Existing");
1089
1120
  expect(result.created).toBe(false);
1090
- expect(await ctx.repo.findAll()).toHaveLength(1);
1121
+ const all = await ctx.repo.findAll();
1122
+ expect(all).toHaveLength(1);
1091
1123
  });
1092
1124
  });
1093
1125
  describe.skipIf(!harness.features.transactions)("withTransaction", () => {
@@ -1098,7 +1130,8 @@ function runStandardRepoConformance(harness) {
1098
1130
  email: "c@x.com"
1099
1131
  }));
1100
1132
  });
1101
- expect(await ctx.repo.findAll({ name: "tx-committed" })).toHaveLength(1);
1133
+ const rows = await ctx.repo.findAll({ name: "tx-committed" });
1134
+ expect(rows).toHaveLength(1);
1102
1135
  });
1103
1136
  it("rolls back on thrown error — no row persists", async () => {
1104
1137
  const err = /* @__PURE__ */ new Error("boom");
@@ -1115,7 +1148,8 @@ function runStandardRepoConformance(harness) {
1115
1148
  caught = e;
1116
1149
  }
1117
1150
  expect(caught).toBe(err);
1118
- expect(await ctx.repo.findAll({ name: "tx-rollback" })).toHaveLength(0);
1151
+ const rows = await ctx.repo.findAll({ name: "tx-rollback" });
1152
+ expect(rows).toHaveLength(0);
1119
1153
  });
1120
1154
  it("reads inside the txRepo see writes inside the same callback", async () => {
1121
1155
  await ctx.repo.withTransaction(async (txRepo) => {
@@ -1123,7 +1157,8 @@ function runStandardRepoConformance(harness) {
1123
1157
  name: "tx-read",
1124
1158
  email: "rr@x.com"
1125
1159
  })), harness.idField);
1126
- expect((await txRepo.getById(id))?.name).toBe("tx-read");
1160
+ const back = await txRepo.getById(id);
1161
+ expect(back?.name).toBe("tx-read");
1127
1162
  });
1128
1163
  });
1129
1164
  });
@@ -1205,7 +1240,7 @@ function runStandardRepoConformance(harness) {
1205
1240
  category: "org-chunk"
1206
1241
  }));
1207
1242
  const progressEvents = [];
1208
- expect((await ctx.repo.purgeByField("category", "org-chunk", { type: "hard" }, {
1243
+ const result = await ctx.repo.purgeByField("category", "org-chunk", { type: "hard" }, {
1209
1244
  batchSize: 10,
1210
1245
  onProgress: (event) => {
1211
1246
  progressEvents.push({
@@ -1213,7 +1248,8 @@ function runStandardRepoConformance(harness) {
1213
1248
  chunkSize: event.chunkSize
1214
1249
  });
1215
1250
  }
1216
- })).processed).toBe(25);
1251
+ });
1252
+ expect(result.processed).toBe(25);
1217
1253
  expect(progressEvents.length).toBe(3);
1218
1254
  expect(progressEvents[0]).toEqual({
1219
1255
  processed: 10,
@@ -1231,7 +1267,8 @@ function runStandardRepoConformance(harness) {
1231
1267
  });
1232
1268
  it.skipIf(skipNoPurge)("idempotent: re-running on the same tenant is a no-op", async () => {
1233
1269
  await seedTwoTenants();
1234
- expect((await ctx.repo.purgeByField("category", "org-a", { type: "hard" })).processed).toBe(3);
1270
+ const first = await ctx.repo.purgeByField("category", "org-a", { type: "hard" });
1271
+ expect(first.processed).toBe(3);
1235
1272
  const second = await ctx.repo.purgeByField("category", "org-a", { type: "hard" });
1236
1273
  expect(second.ok).toBe(true);
1237
1274
  expect(second.processed).toBe(0);
@@ -1240,7 +1277,8 @@ function runStandardRepoConformance(harness) {
1240
1277
  await seedTwoTenants();
1241
1278
  const totalBefore = await ctx.repo.count({});
1242
1279
  await ctx.repo.purgeByField("category", "org-a", { type: "hard" });
1243
- expect(await ctx.repo.count({})).toBe(totalBefore - 3);
1280
+ const totalAfter = await ctx.repo.count({});
1281
+ expect(totalAfter).toBe(totalBefore - 3);
1244
1282
  expect(await ctx.repo.count({ category: "org-b" })).toBe(2);
1245
1283
  });
1246
1284
  it.skipIf(skipNoPurge)("abort signal: stops between chunks, returns partial count", async () => {
@@ -1258,7 +1296,8 @@ function runStandardRepoConformance(harness) {
1258
1296
  });
1259
1297
  expect(result.ok).toBe(false);
1260
1298
  expect(result.processed).toBe(10);
1261
- expect(await ctx.repo.count({ category: "org-abort" })).toBe(15);
1299
+ const remaining = await ctx.repo.count({ category: "org-abort" });
1300
+ expect(remaining).toBe(15);
1262
1301
  });
1263
1302
  it.skipIf(skipNoPurge)("retry policy is plumbed through (default no retry, opt-in works)", async () => {
1264
1303
  await seedTwoTenants();
@@ -1330,7 +1369,7 @@ function runStandardRepoConformance(harness) {
1330
1369
  await seedTenants(25, 0);
1331
1370
  const memory = makeSink();
1332
1371
  const events = [];
1333
- expect((await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink, {
1372
+ const result = await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink, {
1334
1373
  batchSize: 10,
1335
1374
  onProgress: (event) => {
1336
1375
  events.push({
@@ -1338,7 +1377,8 @@ function runStandardRepoConformance(harness) {
1338
1377
  chunkSize: event.chunkSize
1339
1378
  });
1340
1379
  }
1341
- })).processed).toBe(25);
1380
+ });
1381
+ expect(result.processed).toBe(25);
1342
1382
  expect(memory.docs).toHaveLength(25);
1343
1383
  expect(events).toEqual([
1344
1384
  {
@@ -1359,7 +1399,8 @@ function runStandardRepoConformance(harness) {
1359
1399
  if (!ctx.repo.archiveByFilter) return;
1360
1400
  await seedTenants(3, 0);
1361
1401
  const memory = makeSink();
1362
- expect((await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink)).processed).toBe(3);
1402
+ const first = await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink);
1403
+ expect(first.processed).toBe(3);
1363
1404
  const second = await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink);
1364
1405
  expect(second.ok).toBe(true);
1365
1406
  expect(second.processed).toBe(0);
@@ -59,7 +59,8 @@ function runLockAdapterConformance(harness) {
59
59
  expect(await adapter.tryAcquire("lock.one", B, 5e3)).toBe(false);
60
60
  });
61
61
  it("parallel acquires resolve to exactly one winner", async () => {
62
- expect((await Promise.all([adapter.tryAcquire("shared.name", A, 5e3), adapter.tryAcquire("shared.name", B, 5e3)])).filter((r) => r === true)).toHaveLength(1);
62
+ const results = await Promise.all([adapter.tryAcquire("shared.name", A, 5e3), adapter.tryAcquire("shared.name", B, 5e3)]);
63
+ expect(results.filter((r) => r === true)).toHaveLength(1);
63
64
  });
64
65
  });
65
66
  describe("release", () => {
@@ -122,13 +123,17 @@ function runLockAdapterConformance(harness) {
122
123
  await sleep(10);
123
124
  await adapter.tryAcquire("cron.outbox", B, 5e3);
124
125
  expect(await adapter.release("cron.outbox", A)).toBe(false);
125
- if (adapter.inspect) expect((await adapter.inspect("cron.outbox"))?.holder).toBe(B);
126
+ if (adapter.inspect) {
127
+ const state = await adapter.inspect("cron.outbox");
128
+ expect(state?.holder).toBe(B);
129
+ }
126
130
  });
127
131
  });
128
132
  describe("stress", () => {
129
133
  it("50 concurrent holders against one name → exactly one winner", async () => {
130
134
  const holders = Array.from({ length: 50 }, (_, i) => `replica-${i}`);
131
- expect((await Promise.all(holders.map((h) => adapter.tryAcquire("contended", h, 5e3)))).filter((r) => r === true)).toHaveLength(1);
135
+ const results = await Promise.all(holders.map((h) => adapter.tryAcquire("contended", h, 5e3)));
136
+ expect(results.filter((r) => r === true)).toHaveLength(1);
132
137
  });
133
138
  it("100 sequential acquire/release cycles leave no residue", async () => {
134
139
  for (let i = 0; i < 100; i++) {
@@ -29,7 +29,6 @@ import { beforeEach, describe, expect, it } from "vitest";
29
29
  const IN_SCOPE = 25;
30
30
  const OUT_SCOPE = 5;
31
31
  const BATCH = 10;
32
- const AMOUNT_EACH = 7;
33
32
  /**
34
33
  * Wrap TenantPurgeOptions with a chunk budget: when the port stops
35
34
  * progressing, the loop would otherwise run forever — the budget aborts
@@ -82,7 +81,7 @@ function runPurgeConformance(harness) {
82
81
  ]);
83
82
  expect(await ctx.countRaw()).toBe(IN_SCOPE);
84
83
  expect(await ctx.countSoftFlagged()).toBe(IN_SCOPE);
85
- expect(await ctx.sumAmount()).toBe(IN_SCOPE * AMOUNT_EACH);
84
+ expect(await ctx.sumAmount()).toBe(175);
86
85
  expect(await ctx.countOutOfScope()).toBe(OUT_SCOPE);
87
86
  });
88
87
  it("anonymize (static): progresses across batches and retains measures", async () => {
@@ -100,7 +99,7 @@ function runPurgeConformance(harness) {
100
99
  5
101
100
  ]);
102
101
  expect(await ctx.countEmail("redacted@example.invalid")).toBe(IN_SCOPE);
103
- expect(await ctx.sumAmount()).toBe(IN_SCOPE * AMOUNT_EACH);
102
+ expect(await ctx.sumAmount()).toBe(175);
104
103
  expect(await ctx.countRaw()).toBe(IN_SCOPE);
105
104
  expect(await ctx.countOutOfScope()).toBe(OUT_SCOPE);
106
105
  });
@@ -113,7 +112,7 @@ function runPurgeConformance(harness) {
113
112
  expect(res.ok).toBe(true);
114
113
  expect(res.processed).toBe(IN_SCOPE);
115
114
  expect(await ctx.countEmail("fn-redacted@example.invalid")).toBe(IN_SCOPE);
116
- expect(await ctx.sumAmount()).toBe(IN_SCOPE * AMOUNT_EACH);
115
+ expect(await ctx.sumAmount()).toBe(175);
117
116
  });
118
117
  it("exact-batch boundary: inScope === batchSize processes each row once", async () => {
119
118
  await ctx.seed(BATCH, OUT_SCOPE);
@@ -152,7 +151,7 @@ function runPurgeConformance(harness) {
152
151
  });
153
152
  expect(res.ok).toBe(false);
154
153
  expect(res.processed).toBe(BATCH);
155
- expect(await ctx.countRaw()).toBe(IN_SCOPE - BATCH);
154
+ expect(await ctx.countRaw()).toBe(15);
156
155
  });
157
156
  });
158
157
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/repo-core",
3
- "version": "0.20.0",
3
+ "version": "0.21.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,
@@ -169,7 +169,7 @@
169
169
  "fast-check": "^4.7.0",
170
170
  "knip": "^6.3.0",
171
171
  "publint": "^0.3.18",
172
- "tsdown": "^0.22.5",
172
+ "tsdown": "^0.22.14",
173
173
  "typescript": "^7.0.2",
174
174
  "vitest": "^4.1.4"
175
175
  },