@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 +12 -0
- package/dist/cache/engine.mjs +2 -1
- package/dist/cache/index.d.mts +2 -2
- package/dist/cache/memory-adapter.d.mts +21 -3
- package/dist/cache/memory-adapter.mjs +20 -2
- package/dist/cache/plugin/read-hooks.mjs +2 -1
- package/dist/cache/tag-index.mjs +1 -1
- package/dist/cache/version-store.mjs +1 -1
- package/dist/errors/duplicate-key.mjs +2 -1
- package/dist/filter/from-record.mjs +1 -3
- package/dist/filter/match.mjs +1 -1
- package/dist/repository/capabilities.d.mts +9 -0
- package/dist/repository/types.d.mts +17 -0
- package/dist/testing/conformance.mjs +95 -54
- package/dist/testing/lock-conformance.mjs +8 -3
- package/dist/testing/purge-conformance.mjs +4 -5
- package/package.json +2 -2
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
|
package/dist/cache/engine.mjs
CHANGED
|
@@ -62,7 +62,8 @@ var CacheEngine = class {
|
|
|
62
62
|
status: "bypass",
|
|
63
63
|
data: void 0
|
|
64
64
|
};
|
|
65
|
-
const
|
|
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
|
package/dist/cache/index.d.mts
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
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
|
|
3
|
-
|
|
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
|
|
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);
|
package/dist/cache/tag-index.mjs
CHANGED
|
@@ -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),
|
|
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 =
|
|
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
|
|
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];
|
package/dist/filter/match.mjs
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
expect(
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
172
|
+
"tsdown": "^0.22.14",
|
|
173
173
|
"typescript": "^7.0.2",
|
|
174
174
|
"vitest": "^4.1.4"
|
|
175
175
|
},
|