@classytic/repo-core 0.20.0 → 0.22.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,24 @@ 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.22.0] - 2026-08-12
8
+
9
+ ### Added — `pages: 0` conformance tests for empty results
10
+
11
+ - `runStandardRepoConformance` now asserts `pages: 0` for empty offset envelopes (both the primary `getAll` path and the `aggregatePaginate` path). The field had no assertion anywhere in the suite; two kit paths diverged silently: the primary offset path computed `ceil(0/limit) = 0` while aggregate and lookup paths used `Math.max(1, ceil(...)) = 1`. The contract is `0` — zero rows fill zero pages — and it is what every kit's primary path already returned. The missing assertion let the drift reach a consumer as golden-fixture breakage when a `lookups` join rerouted a list read onto the divergent path.
12
+
13
+ ## [0.21.0] - 2026-08-10
14
+
15
+ ### Added — LRU-bounded `createMemoryCacheAdapter`
16
+
17
+ - `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.
18
+ - `MemoryCacheAdapterOptions` exported from `@classytic/repo-core/cache`.
19
+
20
+ ### Added — timezone-aware date buckets (`AggDateBucket.timezone`)
21
+
22
+ - `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.
23
+ - `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`.
24
+
7
25
  ## [0.20.0] - 2026-08-04
8
26
 
9
27
  ### 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",
@@ -979,6 +1003,31 @@ function runStandardRepoConformance(harness) {
979
1003
  expect(out.data).toHaveLength(1);
980
1004
  expect(out.total).toBe(1);
981
1005
  });
1006
+ it("empty result reports pages: 0 (offset envelope)", async () => {
1007
+ const out = await ctx.repo.getAll({
1008
+ filters: { count: 999999 },
1009
+ page: 1,
1010
+ limit: 10
1011
+ });
1012
+ expect(out.data).toHaveLength(0);
1013
+ expect(out.total).toBe(0);
1014
+ expect(out.pages).toBe(0);
1015
+ });
1016
+ it.skipIf(skipNoAgg)("empty result reports pages: 0 (aggregatePaginate offset envelope)", async () => {
1017
+ if (!ctx.repo.aggregatePaginate) return;
1018
+ const result = await ctx.repo.aggregatePaginate.bind(ctx.repo)({
1019
+ filter: eq("category", "__no_such_category__"),
1020
+ groupBy: "category",
1021
+ measures: { n: { op: "count" } },
1022
+ page: 1,
1023
+ limit: 10
1024
+ });
1025
+ expect(result.method).toBe("offset");
1026
+ if (result.method !== "offset") throw new Error("expected offset envelope");
1027
+ expect(result.data).toHaveLength(0);
1028
+ expect(result.total).toBe(0);
1029
+ expect(result.pages).toBe(0);
1030
+ });
982
1031
  });
983
1032
  describe("Filter IR compilation parity", () => {
984
1033
  beforeEach(async () => {
@@ -1016,10 +1065,12 @@ function runStandardRepoConformance(harness) {
1016
1065
  ]);
1017
1066
  });
1018
1067
  it("in_([]) matches nothing (not everything)", async () => {
1019
- expect(await ctx.repo.findAll(in_("category", []))).toHaveLength(0);
1068
+ const rows = await ctx.repo.findAll(in_("category", []));
1069
+ expect(rows).toHaveLength(0);
1020
1070
  });
1021
1071
  it("in_ with non-empty list matches those values", async () => {
1022
- expect(await ctx.repo.findAll(in_("category", ["a"]))).toHaveLength(2);
1072
+ const rows = await ctx.repo.findAll(in_("category", ["a"]));
1073
+ expect(rows).toHaveLength(2);
1023
1074
  });
1024
1075
  it("eq null matches rows where the field is null", async () => {
1025
1076
  const rows = await ctx.repo.findAll(isNull("category"));
@@ -1027,16 +1078,20 @@ function runStandardRepoConformance(harness) {
1027
1078
  expect(rows[0]?.name).toBe("nullnote");
1028
1079
  });
1029
1080
  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"]);
1081
+ const names = (await ctx.repo.findAll(ne("category", "a"))).map((r) => r.name).sort();
1082
+ expect(names).toEqual(["back", "under"]);
1031
1083
  });
1032
1084
  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"]);
1085
+ const rows = await ctx.repo.findAll(like("notes", "50\\% off"));
1086
+ expect(rows.map((r) => r.name)).toEqual(["pct"]);
1034
1087
  });
1035
1088
  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"]);
1089
+ const rows = await ctx.repo.findAll(like("notes", "file\\_name.txt"));
1090
+ expect(rows.map((r) => r.name)).toEqual(["under"]);
1037
1091
  });
1038
1092
  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"]);
1093
+ const names = (await ctx.repo.findAll(or(and(eq("category", "a"), gt("count", 1)), eq("name", "back")))).map((r) => r.name).sort();
1094
+ expect(names).toEqual(["back", "pct"]);
1040
1095
  });
1041
1096
  });
1042
1097
  describe.skipIf(!harness.features.duplicateKeyError)("isDuplicateKeyError", () => {
@@ -1073,7 +1128,8 @@ function runStandardRepoConformance(harness) {
1073
1128
  }));
1074
1129
  expect(result.doc.name).toBe("Fresh");
1075
1130
  expect(result.created).toBe(true);
1076
- expect(await ctx.repo.findAll()).toHaveLength(1);
1131
+ const all = await ctx.repo.findAll();
1132
+ expect(all).toHaveLength(1);
1077
1133
  });
1078
1134
  it("returns existing row when filter matches (created: false, no insert)", async () => {
1079
1135
  if (!ctx.repo.getOrCreate) return;
@@ -1087,7 +1143,8 @@ function runStandardRepoConformance(harness) {
1087
1143
  }));
1088
1144
  expect(result.doc.name).toBe("Existing");
1089
1145
  expect(result.created).toBe(false);
1090
- expect(await ctx.repo.findAll()).toHaveLength(1);
1146
+ const all = await ctx.repo.findAll();
1147
+ expect(all).toHaveLength(1);
1091
1148
  });
1092
1149
  });
1093
1150
  describe.skipIf(!harness.features.transactions)("withTransaction", () => {
@@ -1098,7 +1155,8 @@ function runStandardRepoConformance(harness) {
1098
1155
  email: "c@x.com"
1099
1156
  }));
1100
1157
  });
1101
- expect(await ctx.repo.findAll({ name: "tx-committed" })).toHaveLength(1);
1158
+ const rows = await ctx.repo.findAll({ name: "tx-committed" });
1159
+ expect(rows).toHaveLength(1);
1102
1160
  });
1103
1161
  it("rolls back on thrown error — no row persists", async () => {
1104
1162
  const err = /* @__PURE__ */ new Error("boom");
@@ -1115,7 +1173,8 @@ function runStandardRepoConformance(harness) {
1115
1173
  caught = e;
1116
1174
  }
1117
1175
  expect(caught).toBe(err);
1118
- expect(await ctx.repo.findAll({ name: "tx-rollback" })).toHaveLength(0);
1176
+ const rows = await ctx.repo.findAll({ name: "tx-rollback" });
1177
+ expect(rows).toHaveLength(0);
1119
1178
  });
1120
1179
  it("reads inside the txRepo see writes inside the same callback", async () => {
1121
1180
  await ctx.repo.withTransaction(async (txRepo) => {
@@ -1123,7 +1182,8 @@ function runStandardRepoConformance(harness) {
1123
1182
  name: "tx-read",
1124
1183
  email: "rr@x.com"
1125
1184
  })), harness.idField);
1126
- expect((await txRepo.getById(id))?.name).toBe("tx-read");
1185
+ const back = await txRepo.getById(id);
1186
+ expect(back?.name).toBe("tx-read");
1127
1187
  });
1128
1188
  });
1129
1189
  });
@@ -1205,7 +1265,7 @@ function runStandardRepoConformance(harness) {
1205
1265
  category: "org-chunk"
1206
1266
  }));
1207
1267
  const progressEvents = [];
1208
- expect((await ctx.repo.purgeByField("category", "org-chunk", { type: "hard" }, {
1268
+ const result = await ctx.repo.purgeByField("category", "org-chunk", { type: "hard" }, {
1209
1269
  batchSize: 10,
1210
1270
  onProgress: (event) => {
1211
1271
  progressEvents.push({
@@ -1213,7 +1273,8 @@ function runStandardRepoConformance(harness) {
1213
1273
  chunkSize: event.chunkSize
1214
1274
  });
1215
1275
  }
1216
- })).processed).toBe(25);
1276
+ });
1277
+ expect(result.processed).toBe(25);
1217
1278
  expect(progressEvents.length).toBe(3);
1218
1279
  expect(progressEvents[0]).toEqual({
1219
1280
  processed: 10,
@@ -1231,7 +1292,8 @@ function runStandardRepoConformance(harness) {
1231
1292
  });
1232
1293
  it.skipIf(skipNoPurge)("idempotent: re-running on the same tenant is a no-op", async () => {
1233
1294
  await seedTwoTenants();
1234
- expect((await ctx.repo.purgeByField("category", "org-a", { type: "hard" })).processed).toBe(3);
1295
+ const first = await ctx.repo.purgeByField("category", "org-a", { type: "hard" });
1296
+ expect(first.processed).toBe(3);
1235
1297
  const second = await ctx.repo.purgeByField("category", "org-a", { type: "hard" });
1236
1298
  expect(second.ok).toBe(true);
1237
1299
  expect(second.processed).toBe(0);
@@ -1240,7 +1302,8 @@ function runStandardRepoConformance(harness) {
1240
1302
  await seedTwoTenants();
1241
1303
  const totalBefore = await ctx.repo.count({});
1242
1304
  await ctx.repo.purgeByField("category", "org-a", { type: "hard" });
1243
- expect(await ctx.repo.count({})).toBe(totalBefore - 3);
1305
+ const totalAfter = await ctx.repo.count({});
1306
+ expect(totalAfter).toBe(totalBefore - 3);
1244
1307
  expect(await ctx.repo.count({ category: "org-b" })).toBe(2);
1245
1308
  });
1246
1309
  it.skipIf(skipNoPurge)("abort signal: stops between chunks, returns partial count", async () => {
@@ -1258,7 +1321,8 @@ function runStandardRepoConformance(harness) {
1258
1321
  });
1259
1322
  expect(result.ok).toBe(false);
1260
1323
  expect(result.processed).toBe(10);
1261
- expect(await ctx.repo.count({ category: "org-abort" })).toBe(15);
1324
+ const remaining = await ctx.repo.count({ category: "org-abort" });
1325
+ expect(remaining).toBe(15);
1262
1326
  });
1263
1327
  it.skipIf(skipNoPurge)("retry policy is plumbed through (default no retry, opt-in works)", async () => {
1264
1328
  await seedTwoTenants();
@@ -1330,7 +1394,7 @@ function runStandardRepoConformance(harness) {
1330
1394
  await seedTenants(25, 0);
1331
1395
  const memory = makeSink();
1332
1396
  const events = [];
1333
- expect((await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink, {
1397
+ const result = await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink, {
1334
1398
  batchSize: 10,
1335
1399
  onProgress: (event) => {
1336
1400
  events.push({
@@ -1338,7 +1402,8 @@ function runStandardRepoConformance(harness) {
1338
1402
  chunkSize: event.chunkSize
1339
1403
  });
1340
1404
  }
1341
- })).processed).toBe(25);
1405
+ });
1406
+ expect(result.processed).toBe(25);
1342
1407
  expect(memory.docs).toHaveLength(25);
1343
1408
  expect(events).toEqual([
1344
1409
  {
@@ -1359,7 +1424,8 @@ function runStandardRepoConformance(harness) {
1359
1424
  if (!ctx.repo.archiveByFilter) return;
1360
1425
  await seedTenants(3, 0);
1361
1426
  const memory = makeSink();
1362
- expect((await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink)).processed).toBe(3);
1427
+ const first = await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink);
1428
+ expect(first.processed).toBe(3);
1363
1429
  const second = await ctx.repo.archiveByFilter({ category: "org-cold" }, memory.sink);
1364
1430
  expect(second.ok).toBe(true);
1365
1431
  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.22.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
  },