@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 +18 -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 +120 -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,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
|
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",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
|
|
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.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.
|
|
172
|
+
"tsdown": "^0.22.14",
|
|
173
173
|
"typescript": "^7.0.2",
|
|
174
174
|
"vitest": "^4.1.4"
|
|
175
175
|
},
|