@classytic/repo-core 0.29.0 → 0.31.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 +1336 -1263
- package/dist/errors/codes.d.mts +10 -1
- package/dist/errors/codes.mjs +9 -1
- package/dist/errors/index.d.mts +2 -2
- package/dist/errors/index.mjs +2 -2
- package/dist/pagination/index.d.mts +3 -1
- package/dist/pagination/index.mjs +3 -1
- package/dist/pagination/merge-keyset.d.mts +50 -0
- package/dist/pagination/merge-keyset.mjs +139 -0
- package/dist/query-parser/grammar.d.mts +6 -0
- package/dist/query-parser/grammar.mjs +21 -4
- package/dist/query-parser/reserved.mjs +2 -0
- package/dist/repository/bulk-upsert.d.mts +59 -0
- package/dist/repository/bulk-upsert.mjs +50 -0
- package/dist/repository/capabilities.d.mts +23 -0
- package/dist/repository/index.d.mts +2 -1
- package/dist/repository/index.mjs +2 -1
- package/dist/repository/types.d.mts +7 -1
- package/dist/sync/index.d.mts +90 -55
- package/dist/sync/index.mjs +0 -0
- package/dist/testing/change-log-conformance.d.mts +21 -0
- package/dist/testing/change-log-conformance.mjs +204 -0
- package/dist/testing/command-stream-conformance.d.mts +15 -0
- package/dist/testing/command-stream-conformance.mjs +163 -0
- package/dist/testing/data-access-conformance.d.mts +89 -0
- package/dist/testing/data-access-conformance.mjs +331 -0
- package/dist/testing/index.d.mts +7 -1
- package/dist/testing/index.mjs +7 -1
- package/dist/testing/merge-keyset-conformance.d.mts +26 -0
- package/dist/testing/merge-keyset-conformance.mjs +85 -0
- package/dist/testing/pagination-property-conformance.d.mts +44 -0
- package/dist/testing/pagination-property-conformance.mjs +160 -0
- package/dist/testing/read-model-property-conformance.d.mts +13 -0
- package/dist/testing/read-model-property-conformance.mjs +74 -0
- package/package.json +1 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { RepoCapabilities } from "../repository/capabilities.mjs";
|
|
2
|
+
import { AggRequest, AggResult } from "../repository/types.mjs";
|
|
3
|
+
import { BulkUpsertOptions, BulkUpsertResult } from "../repository/bulk-upsert.mjs";
|
|
4
|
+
//#region src/testing/data-access-conformance.d.ts
|
|
5
|
+
/** `getByIds(..., { preserveOrder: true })` reached through the kit's own signature. */
|
|
6
|
+
interface OrderedIdsFixture {
|
|
7
|
+
/** Insert one record per name; returns their ids in the same order. */
|
|
8
|
+
insert(names: readonly string[]): Promise<string[]>;
|
|
9
|
+
getOrdered(ids: readonly string[], options: {
|
|
10
|
+
chunkSize?: number;
|
|
11
|
+
}): Promise<ReadonlyArray<{
|
|
12
|
+
name: string;
|
|
13
|
+
} | undefined>>;
|
|
14
|
+
/** A well-formed id that matches no record. */
|
|
15
|
+
missingId: string;
|
|
16
|
+
cleanup(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
/** A tenant-scoped base and a tenant-scoped, soft-deletable sibling it can join. */
|
|
19
|
+
interface ScopedJoinFixture {
|
|
20
|
+
/** The base repository's portable aggregate. Base rows carry `key`. */
|
|
21
|
+
aggregate(req: AggRequest, scope: Record<string, unknown>): Promise<AggResult>;
|
|
22
|
+
/** Name `lookups[].from` takes for the joined sibling. */
|
|
23
|
+
joined: string;
|
|
24
|
+
seedBase(tenant: string, keys: readonly string[]): Promise<void>;
|
|
25
|
+
/** Joined rows carry `key` and `qty`; `deleted` rows are soft-deleted. */
|
|
26
|
+
seedJoined(tenant: string, rows: ReadonlyArray<{
|
|
27
|
+
key: string;
|
|
28
|
+
qty: number;
|
|
29
|
+
deleted?: boolean;
|
|
30
|
+
}>): Promise<void>;
|
|
31
|
+
/** Call options that scope a read to `tenant`. */
|
|
32
|
+
scope(tenant: string): Record<string, unknown>;
|
|
33
|
+
cleanup(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
/** A tenant-scoped collection keyed uniquely by (tenant, `sku`). Rows: `{ sku, qty, name, firstSeen }`. */
|
|
36
|
+
interface BulkUpsertFixture {
|
|
37
|
+
bulkUpsert(rows: readonly Record<string, unknown>[], options: BulkUpsertOptions, tenant: string): Promise<BulkUpsertResult>;
|
|
38
|
+
read(tenant: string): Promise<readonly Record<string, unknown>[]>;
|
|
39
|
+
/** `bulkUpsert` keyed on `name`, which has NO unique index: must be refused. */
|
|
40
|
+
bulkUpsertByNonUniqueKey(rows: readonly Record<string, unknown>[], tenant: string): Promise<unknown>;
|
|
41
|
+
cleanup(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
/** Source facts `{ period, rate, tax }` and a totals read model keyed (tenant, period, rate). */
|
|
44
|
+
interface ReadModelFixture {
|
|
45
|
+
seedSource(tenant: string, rows: ReadonlyArray<{
|
|
46
|
+
period: string;
|
|
47
|
+
rate: number;
|
|
48
|
+
tax: number;
|
|
49
|
+
}>): Promise<void>;
|
|
50
|
+
/** Rebuild the tenant's totals from its source facts. */
|
|
51
|
+
rebuild(tenant: string): Promise<void>;
|
|
52
|
+
/** The same rebuild, issued inside a transaction: must reject. */
|
|
53
|
+
rebuildInTransaction(tenant: string): Promise<unknown>;
|
|
54
|
+
/** Drift between the source-derived totals and the read model, for the tenant. */
|
|
55
|
+
reconcile(tenant: string): Promise<ReadonlyArray<{
|
|
56
|
+
key: Record<string, unknown>;
|
|
57
|
+
expected: unknown;
|
|
58
|
+
actual: unknown;
|
|
59
|
+
}>>;
|
|
60
|
+
/** Corrupt one stored total. */
|
|
61
|
+
tamper(tenant: string, period: string, rate: number): Promise<void>;
|
|
62
|
+
/** Apply increments in one unit of work; a repeated dedupeKey must count once. */
|
|
63
|
+
applyIncrements(tenant: string, grains: ReadonlyArray<{
|
|
64
|
+
period: string;
|
|
65
|
+
rate: number;
|
|
66
|
+
tax: number;
|
|
67
|
+
dedupeKey: string;
|
|
68
|
+
}>): Promise<void>;
|
|
69
|
+
readTotals(tenant: string): Promise<ReadonlyArray<{
|
|
70
|
+
period: string;
|
|
71
|
+
rate: number;
|
|
72
|
+
tax: number;
|
|
73
|
+
}>>;
|
|
74
|
+
cleanup(): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
interface DataAccessConformanceHarness {
|
|
77
|
+
name: string;
|
|
78
|
+
features: RepoCapabilities;
|
|
79
|
+
orderedIds?: () => Promise<OrderedIdsFixture>;
|
|
80
|
+
scopedJoin?: () => Promise<ScopedJoinFixture>;
|
|
81
|
+
bulkUpsert?: () => Promise<BulkUpsertFixture>;
|
|
82
|
+
readModel?: () => Promise<ReadModelFixture>;
|
|
83
|
+
}
|
|
84
|
+
type DataAccessCapability = 'getByIdsOrdered' | 'scopedJoins' | 'bulkUpsert' | 'readModel';
|
|
85
|
+
/** Whether a group runs, is skipped (capability not declared), or fails (declared, no fixture). */
|
|
86
|
+
declare function dataAccessGroupState(features: RepoCapabilities, capability: DataAccessCapability, fixture: unknown): 'run' | 'skip' | 'missing-fixture';
|
|
87
|
+
declare function runDataAccessConformance(harness: DataAccessConformanceHarness): void;
|
|
88
|
+
//#endregion
|
|
89
|
+
export { BulkUpsertFixture, DataAccessCapability, DataAccessConformanceHarness, OrderedIdsFixture, ReadModelFixture, ScopedJoinFixture, dataAccessGroupState, runDataAccessConformance };
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
//#region src/testing/data-access-conformance.ts
|
|
3
|
+
/**
|
|
4
|
+
* Data-access conformance: behaviour groups a kit OPTS INTO through `RepoCapabilities`.
|
|
5
|
+
*
|
|
6
|
+
* Each group runs only when its capability is declared. An undeclared one is reported as a
|
|
7
|
+
* skipped group whose title names the missing capability (never a silent pass), and a declared
|
|
8
|
+
* capability without its fixture fails, because an unproven declaration is decoration (FL4).
|
|
9
|
+
* Groups: `getByIdsOrdered`, `scopedJoins`, `bulkUpsert`, `readModel`.
|
|
10
|
+
*/
|
|
11
|
+
/** Whether a group runs, is skipped (capability not declared), or fails (declared, no fixture). */
|
|
12
|
+
function dataAccessGroupState(features, capability, fixture) {
|
|
13
|
+
if (features[capability] !== true) return "skip";
|
|
14
|
+
return fixture === void 0 ? "missing-fixture" : "run";
|
|
15
|
+
}
|
|
16
|
+
/** A group's fixture lifecycle, owned once here: each case reads the current fixture via `fx()`. */
|
|
17
|
+
function gated(harness, capability, title, fixture, cases) {
|
|
18
|
+
const state = dataAccessGroupState(harness.features, capability, fixture);
|
|
19
|
+
let current;
|
|
20
|
+
const fx = () => {
|
|
21
|
+
if (!current) throw new Error(`${title}: fixture not built`);
|
|
22
|
+
return current;
|
|
23
|
+
};
|
|
24
|
+
if (state === "skip") {
|
|
25
|
+
describe.skip(`${title} [skipped: capability '${capability}' not declared]`, () => cases(fx));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (state === "missing-fixture") {
|
|
29
|
+
describe(title, () => {
|
|
30
|
+
it(`declares '${capability}' and supplies its fixture`, () => {
|
|
31
|
+
throw new Error(`${harness.name} declares '${capability}' but supplies no fixture for it`);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
describe(title, () => {
|
|
37
|
+
beforeEach(async () => {
|
|
38
|
+
current = await fixture();
|
|
39
|
+
});
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
await current?.cleanup();
|
|
42
|
+
current = void 0;
|
|
43
|
+
});
|
|
44
|
+
cases(fx);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function runDataAccessConformance(harness) {
|
|
48
|
+
describe(`[data-access conformance] ${harness.name}`, () => {
|
|
49
|
+
gated(harness, "getByIdsOrdered", "getByIds (ordered, chunked)", harness.orderedIds, (fx) => {
|
|
50
|
+
it("aligns with the input: misses are undefined, duplicates repeat", async () => {
|
|
51
|
+
const [a, b] = await fx().insert(["ord-a", "ord-b"]);
|
|
52
|
+
const out = await fx().getOrdered([
|
|
53
|
+
b,
|
|
54
|
+
fx().missingId,
|
|
55
|
+
a,
|
|
56
|
+
b
|
|
57
|
+
], {});
|
|
58
|
+
expect(out.map((d) => d?.name)).toEqual([
|
|
59
|
+
"ord-b",
|
|
60
|
+
void 0,
|
|
61
|
+
"ord-a",
|
|
62
|
+
"ord-b"
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
it("gives the same answer at any chunk size", async () => {
|
|
66
|
+
const names = Array.from({ length: 7 }, (_, i) => `chunk-${i}`);
|
|
67
|
+
const shuffled = [...await fx().insert(names)].reverse();
|
|
68
|
+
const one = await fx().getOrdered(shuffled, { chunkSize: 1 });
|
|
69
|
+
const all = await fx().getOrdered(shuffled, {});
|
|
70
|
+
expect(one.map((d) => d?.name)).toEqual([...names].reverse());
|
|
71
|
+
expect(all.map((d) => d?.name)).toEqual(one.map((d) => d?.name));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
gated(harness, "scopedJoins", "scoped joins", harness.scopedJoin, (fx) => {
|
|
75
|
+
const joinSum = (from) => ({
|
|
76
|
+
lookups: [{
|
|
77
|
+
from,
|
|
78
|
+
localField: "key",
|
|
79
|
+
foreignField: "key",
|
|
80
|
+
as: "j"
|
|
81
|
+
}],
|
|
82
|
+
groupBy: "key",
|
|
83
|
+
measures: { n: { op: "count" } }
|
|
84
|
+
});
|
|
85
|
+
it("a join reads only the caller tenant live rows of the joined collection", async () => {
|
|
86
|
+
await fx().seedBase("t-a", ["shared", "b-only"]);
|
|
87
|
+
await fx().seedBase("t-b", ["shared"]);
|
|
88
|
+
await fx().seedJoined("t-a", [{
|
|
89
|
+
key: "shared",
|
|
90
|
+
qty: 5
|
|
91
|
+
}, {
|
|
92
|
+
key: "shared",
|
|
93
|
+
qty: 99,
|
|
94
|
+
deleted: true
|
|
95
|
+
}]);
|
|
96
|
+
await fx().seedJoined("t-b", [{
|
|
97
|
+
key: "shared",
|
|
98
|
+
qty: 7
|
|
99
|
+
}, {
|
|
100
|
+
key: "b-only",
|
|
101
|
+
qty: 3
|
|
102
|
+
}]);
|
|
103
|
+
const { rows } = await fx().aggregate({
|
|
104
|
+
lookups: [{
|
|
105
|
+
from: fx().joined,
|
|
106
|
+
localField: "key",
|
|
107
|
+
foreignField: "key",
|
|
108
|
+
as: "j",
|
|
109
|
+
single: true
|
|
110
|
+
}],
|
|
111
|
+
groupBy: "key",
|
|
112
|
+
measures: { qty: {
|
|
113
|
+
op: "sum",
|
|
114
|
+
field: "j.qty"
|
|
115
|
+
} },
|
|
116
|
+
sort: { key: 1 }
|
|
117
|
+
}, fx().scope("t-a"));
|
|
118
|
+
const byKey = Object.fromEntries(rows.map((r) => [String(r["key"]), r["qty"] ?? 0]));
|
|
119
|
+
expect(byKey["shared"]).toBe(5);
|
|
120
|
+
expect(byKey["b-only"]).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
it("a join into a name the kit cannot resolve is refused, not an empty join", async () => {
|
|
123
|
+
await fx().seedBase("t-a", ["shared"]);
|
|
124
|
+
await expect(fx().aggregate(joinSum(fx().joined), fx().scope("t-a"))).resolves.toBeDefined();
|
|
125
|
+
await expect(fx().aggregate(joinSum(`${fx().joined}_no_such_collection`), fx().scope("t-a"))).rejects.toThrow();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
gated(harness, "bulkUpsert", "bulkUpsert", harness.bulkUpsert, (fx) => {
|
|
129
|
+
const at = /* @__PURE__ */ new Date("2026-01-01T00:00:00.000Z");
|
|
130
|
+
it("reports inserted, then updated vs unchanged, per row and in order", async () => {
|
|
131
|
+
const first = await fx().bulkUpsert([{
|
|
132
|
+
sku: "A",
|
|
133
|
+
qty: 1,
|
|
134
|
+
name: "alpha"
|
|
135
|
+
}, {
|
|
136
|
+
sku: "B",
|
|
137
|
+
qty: 2,
|
|
138
|
+
name: "beta"
|
|
139
|
+
}], { key: ["sku"] }, "t-a");
|
|
140
|
+
expect(first.results.map((r) => r.outcome)).toEqual(["inserted", "inserted"]);
|
|
141
|
+
expect(first.results.map((r) => r.key)).toEqual([{ sku: "A" }, { sku: "B" }]);
|
|
142
|
+
const second = await fx().bulkUpsert([{
|
|
143
|
+
sku: "A",
|
|
144
|
+
qty: 1,
|
|
145
|
+
name: "alpha"
|
|
146
|
+
}, {
|
|
147
|
+
sku: "B",
|
|
148
|
+
qty: 2,
|
|
149
|
+
name: "beta-2"
|
|
150
|
+
}], { key: ["sku"] }, "t-a");
|
|
151
|
+
expect(second.results.map((r) => r.outcome)).toEqual(["unchanged", "updated"]);
|
|
152
|
+
expect(second).toMatchObject({
|
|
153
|
+
inserted: 0,
|
|
154
|
+
updated: 1,
|
|
155
|
+
unchanged: 1,
|
|
156
|
+
failed: 0
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
it("inc accumulates and setOnInsert is written once", async () => {
|
|
160
|
+
const opts = {
|
|
161
|
+
key: ["sku"],
|
|
162
|
+
inc: ["qty"],
|
|
163
|
+
setOnInsert: ["firstSeen"]
|
|
164
|
+
};
|
|
165
|
+
await fx().bulkUpsert([{
|
|
166
|
+
sku: "A",
|
|
167
|
+
qty: 5,
|
|
168
|
+
firstSeen: at
|
|
169
|
+
}], opts, "t-a");
|
|
170
|
+
await fx().bulkUpsert([{
|
|
171
|
+
sku: "A",
|
|
172
|
+
qty: 7,
|
|
173
|
+
firstSeen: /* @__PURE__ */ new Date("2027-01-01T00:00:00.000Z")
|
|
174
|
+
}], opts, "t-a");
|
|
175
|
+
const [row] = await fx().read("t-a");
|
|
176
|
+
expect(row?.["qty"]).toBe(12);
|
|
177
|
+
expect(new Date(String(row?.["firstSeen"])).toISOString()).toBe(at.toISOString());
|
|
178
|
+
});
|
|
179
|
+
it("the tenant scope is part of the key: another tenant row is never touched", async () => {
|
|
180
|
+
const opts = {
|
|
181
|
+
key: ["sku"],
|
|
182
|
+
inc: ["qty"]
|
|
183
|
+
};
|
|
184
|
+
await fx().bulkUpsert([{
|
|
185
|
+
sku: "A",
|
|
186
|
+
qty: 1
|
|
187
|
+
}], opts, "t-b");
|
|
188
|
+
await fx().bulkUpsert([{
|
|
189
|
+
sku: "A",
|
|
190
|
+
qty: 1
|
|
191
|
+
}], opts, "t-a");
|
|
192
|
+
await fx().bulkUpsert([{
|
|
193
|
+
sku: "A",
|
|
194
|
+
qty: 1
|
|
195
|
+
}], opts, "t-a");
|
|
196
|
+
expect((await fx().read("t-a")).map((r) => r["qty"])).toEqual([2]);
|
|
197
|
+
expect((await fx().read("t-b")).map((r) => r["qty"])).toEqual([1]);
|
|
198
|
+
});
|
|
199
|
+
it("a row missing a key field fails alone; the rest land", async () => {
|
|
200
|
+
const res = await fx().bulkUpsert([{ qty: 1 }, {
|
|
201
|
+
sku: "C",
|
|
202
|
+
qty: 3
|
|
203
|
+
}], { key: ["sku"] }, "t-a");
|
|
204
|
+
expect(res.results.map((r) => [r.outcome, r.code])).toEqual([["failed", "invalid_row"], ["inserted", void 0]]);
|
|
205
|
+
});
|
|
206
|
+
it("a key without a unique index is refused before any write", async () => {
|
|
207
|
+
await expect(fx().bulkUpsertByNonUniqueKey([{
|
|
208
|
+
sku: "Z",
|
|
209
|
+
name: "zed",
|
|
210
|
+
qty: 1
|
|
211
|
+
}], "t-a")).rejects.toThrow();
|
|
212
|
+
expect(await fx().read("t-a")).toHaveLength(0);
|
|
213
|
+
});
|
|
214
|
+
it("a field routed twice is refused", async () => {
|
|
215
|
+
await expect(fx().bulkUpsert([{
|
|
216
|
+
sku: "A",
|
|
217
|
+
qty: 1
|
|
218
|
+
}], {
|
|
219
|
+
key: ["sku"],
|
|
220
|
+
inc: ["qty"],
|
|
221
|
+
set: ["qty"]
|
|
222
|
+
}, "t-a")).rejects.toThrow();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
gated(harness, "readModel", "read model", harness.readModel, (fx) => {
|
|
226
|
+
const facts = [
|
|
227
|
+
{
|
|
228
|
+
period: "2026-01",
|
|
229
|
+
rate: 15,
|
|
230
|
+
tax: 100
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
period: "2026-01",
|
|
234
|
+
rate: 15,
|
|
235
|
+
tax: 50
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
period: "2026-01",
|
|
239
|
+
rate: 5,
|
|
240
|
+
tax: 7
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
period: "2026-02",
|
|
244
|
+
rate: 15,
|
|
245
|
+
tax: 30
|
|
246
|
+
}
|
|
247
|
+
];
|
|
248
|
+
const byGrain = (rows) => rows.map((t) => [
|
|
249
|
+
t.period,
|
|
250
|
+
t.rate,
|
|
251
|
+
t.tax
|
|
252
|
+
]).sort((a, b) => String(a).localeCompare(String(b)));
|
|
253
|
+
it("rebuild then reconcile reports zero drift", async () => {
|
|
254
|
+
await fx().seedSource("t-a", facts);
|
|
255
|
+
await fx().rebuild("t-a");
|
|
256
|
+
expect(await fx().reconcile("t-a")).toEqual([]);
|
|
257
|
+
expect(byGrain(await fx().readTotals("t-a"))).toEqual(byGrain([
|
|
258
|
+
{
|
|
259
|
+
period: "2026-01",
|
|
260
|
+
rate: 15,
|
|
261
|
+
tax: 150
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
period: "2026-01",
|
|
265
|
+
rate: 5,
|
|
266
|
+
tax: 7
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
period: "2026-02",
|
|
270
|
+
rate: 15,
|
|
271
|
+
tax: 30
|
|
272
|
+
}
|
|
273
|
+
]));
|
|
274
|
+
});
|
|
275
|
+
it("reconcile reports a corrupted total with its expected and actual value", async () => {
|
|
276
|
+
await fx().seedSource("t-a", facts);
|
|
277
|
+
await fx().rebuild("t-a");
|
|
278
|
+
await fx().tamper("t-a", "2026-01", 15);
|
|
279
|
+
const drift = await fx().reconcile("t-a");
|
|
280
|
+
expect(drift).toHaveLength(1);
|
|
281
|
+
expect(drift[0]?.key).toMatchObject({
|
|
282
|
+
period: "2026-01",
|
|
283
|
+
rate: 15
|
|
284
|
+
});
|
|
285
|
+
expect(drift[0]?.expected).not.toEqual(drift[0]?.actual);
|
|
286
|
+
});
|
|
287
|
+
it("rebuilding one tenant never touches another tenant read model", async () => {
|
|
288
|
+
await fx().seedSource("t-a", facts);
|
|
289
|
+
await fx().seedSource("t-b", [{
|
|
290
|
+
period: "2026-01",
|
|
291
|
+
rate: 15,
|
|
292
|
+
tax: 1
|
|
293
|
+
}]);
|
|
294
|
+
await fx().rebuild("t-b");
|
|
295
|
+
await fx().rebuild("t-a");
|
|
296
|
+
expect((await fx().readTotals("t-b")).map((t) => t.tax)).toEqual([1]);
|
|
297
|
+
});
|
|
298
|
+
it("applyIncrements counts a repeated dedupeKey once", async () => {
|
|
299
|
+
await fx().applyIncrements("t-a", [{
|
|
300
|
+
period: "2026-03",
|
|
301
|
+
rate: 15,
|
|
302
|
+
tax: 10,
|
|
303
|
+
dedupeKey: "e1"
|
|
304
|
+
}, {
|
|
305
|
+
period: "2026-03",
|
|
306
|
+
rate: 15,
|
|
307
|
+
tax: 5,
|
|
308
|
+
dedupeKey: "e2"
|
|
309
|
+
}]);
|
|
310
|
+
await fx().applyIncrements("t-a", [{
|
|
311
|
+
period: "2026-03",
|
|
312
|
+
rate: 15,
|
|
313
|
+
tax: 10,
|
|
314
|
+
dedupeKey: "e1"
|
|
315
|
+
}, {
|
|
316
|
+
period: "2026-03",
|
|
317
|
+
rate: 15,
|
|
318
|
+
tax: 5,
|
|
319
|
+
dedupeKey: "e3"
|
|
320
|
+
}]);
|
|
321
|
+
expect((await fx().readTotals("t-a")).map((t) => t.tax)).toEqual([20]);
|
|
322
|
+
});
|
|
323
|
+
it("a rebuild inside a transaction is refused", async () => {
|
|
324
|
+
await fx().seedSource("t-a", facts);
|
|
325
|
+
await expect(fx().rebuildInTransaction("t-a")).rejects.toThrow();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
//#endregion
|
|
331
|
+
export { dataAccessGroupState, runDataAccessConformance };
|
package/dist/testing/index.d.mts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { AggregateOpsSupport } from "../repository/capabilities.mjs";
|
|
2
|
+
import { ChangeLogConformanceHarness, changeLogStoreCases, runChangeLogStoreConformance } from "./change-log-conformance.mjs";
|
|
3
|
+
import { CommandStreamConformanceHarness, commandStreamStoreCases, runCommandStreamStoreConformance } from "./command-stream-conformance.mjs";
|
|
2
4
|
import { ConformanceContext, ConformanceDoc, ConformanceFeatures, ConformanceHarness } from "./types.mjs";
|
|
3
5
|
import { runStandardRepoConformance } from "./conformance.mjs";
|
|
6
|
+
import { BulkUpsertFixture, DataAccessCapability, DataAccessConformanceHarness, OrderedIdsFixture, ReadModelFixture, ScopedJoinFixture, dataAccessGroupState, runDataAccessConformance } from "./data-access-conformance.mjs";
|
|
4
7
|
import { IdempotencyConformanceHarness, idempotencyStoreCases, runIdempotencyStoreConformance } from "./idempotency-conformance.mjs";
|
|
5
8
|
import { LockConformanceHarness, runLockAdapterConformance } from "./lock-conformance.mjs";
|
|
9
|
+
import { MergeKeysetFixture, MergeKeysetHarness, runMergeKeysetConformance } from "./merge-keyset-conformance.mjs";
|
|
10
|
+
import { PaginationPropertyFixture, PaginationPropertyHarness, PropertyRow, runPaginationPropertyConformance } from "./pagination-property-conformance.mjs";
|
|
6
11
|
import { PurgeConformanceContext, PurgeConformanceHarness, runPurgeConformance } from "./purge-conformance.mjs";
|
|
7
12
|
import { QUERY_GRAMMAR_DOCS, QUERY_GRAMMAR_FILTER_CASES, QUERY_GRAMMAR_PAGING_CASES, QUERY_GRAMMAR_REFUSED, QueryGrammarCaseOptions, QueryGrammarConformanceOptions, QueryGrammarHarness, QueryGrammarParsed, runQueryGrammarConformance } from "./query-grammar-conformance.mjs";
|
|
13
|
+
import { ReadModelPropertyHarness, runReadModelPropertyConformance } from "./read-model-property-conformance.mjs";
|
|
8
14
|
import { UsageConformanceHarness, runUsageStoreContract } from "./usage-conformance.mjs";
|
|
9
|
-
export { type AggregateOpsSupport, type ConformanceContext, type ConformanceDoc, type ConformanceFeatures, type ConformanceHarness, type IdempotencyConformanceHarness, type LockConformanceHarness, type PurgeConformanceContext, type PurgeConformanceHarness, QUERY_GRAMMAR_DOCS, QUERY_GRAMMAR_FILTER_CASES, QUERY_GRAMMAR_PAGING_CASES, QUERY_GRAMMAR_REFUSED, type QueryGrammarCaseOptions, type QueryGrammarConformanceOptions, type QueryGrammarHarness, type QueryGrammarParsed, type UsageConformanceHarness, idempotencyStoreCases, runIdempotencyStoreConformance, runLockAdapterConformance, runPurgeConformance, runQueryGrammarConformance, runStandardRepoConformance, runUsageStoreContract };
|
|
15
|
+
export { type AggregateOpsSupport, type BulkUpsertFixture, type ChangeLogConformanceHarness, type CommandStreamConformanceHarness, type ConformanceContext, type ConformanceDoc, type ConformanceFeatures, type ConformanceHarness, type DataAccessCapability, type DataAccessConformanceHarness, type IdempotencyConformanceHarness, type LockConformanceHarness, type MergeKeysetFixture, type MergeKeysetHarness, type OrderedIdsFixture, type PaginationPropertyFixture, type PaginationPropertyHarness, type PropertyRow, type PurgeConformanceContext, type PurgeConformanceHarness, QUERY_GRAMMAR_DOCS, QUERY_GRAMMAR_FILTER_CASES, QUERY_GRAMMAR_PAGING_CASES, QUERY_GRAMMAR_REFUSED, type QueryGrammarCaseOptions, type QueryGrammarConformanceOptions, type QueryGrammarHarness, type QueryGrammarParsed, type ReadModelFixture, type ReadModelPropertyHarness, type ScopedJoinFixture, type UsageConformanceHarness, changeLogStoreCases, commandStreamStoreCases, dataAccessGroupState, idempotencyStoreCases, runChangeLogStoreConformance, runCommandStreamStoreConformance, runDataAccessConformance, runIdempotencyStoreConformance, runLockAdapterConformance, runMergeKeysetConformance, runPaginationPropertyConformance, runPurgeConformance, runQueryGrammarConformance, runReadModelPropertyConformance, runStandardRepoConformance, runUsageStoreContract };
|
package/dist/testing/index.mjs
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
import { changeLogStoreCases, runChangeLogStoreConformance } from "./change-log-conformance.mjs";
|
|
2
|
+
import { commandStreamStoreCases, runCommandStreamStoreConformance } from "./command-stream-conformance.mjs";
|
|
1
3
|
import { runStandardRepoConformance } from "./conformance.mjs";
|
|
4
|
+
import { dataAccessGroupState, runDataAccessConformance } from "./data-access-conformance.mjs";
|
|
2
5
|
import { idempotencyStoreCases, runIdempotencyStoreConformance } from "./idempotency-conformance.mjs";
|
|
3
6
|
import { runLockAdapterConformance } from "./lock-conformance.mjs";
|
|
7
|
+
import { runMergeKeysetConformance } from "./merge-keyset-conformance.mjs";
|
|
8
|
+
import { runPaginationPropertyConformance } from "./pagination-property-conformance.mjs";
|
|
4
9
|
import { runPurgeConformance } from "./purge-conformance.mjs";
|
|
5
10
|
import { QUERY_GRAMMAR_DOCS, QUERY_GRAMMAR_FILTER_CASES, QUERY_GRAMMAR_PAGING_CASES, QUERY_GRAMMAR_REFUSED, runQueryGrammarConformance } from "./query-grammar-conformance.mjs";
|
|
11
|
+
import { runReadModelPropertyConformance } from "./read-model-property-conformance.mjs";
|
|
6
12
|
import { runUsageStoreContract } from "./usage-conformance.mjs";
|
|
7
|
-
export { QUERY_GRAMMAR_DOCS, QUERY_GRAMMAR_FILTER_CASES, QUERY_GRAMMAR_PAGING_CASES, QUERY_GRAMMAR_REFUSED, idempotencyStoreCases, runIdempotencyStoreConformance, runLockAdapterConformance, runPurgeConformance, runQueryGrammarConformance, runStandardRepoConformance, runUsageStoreContract };
|
|
13
|
+
export { QUERY_GRAMMAR_DOCS, QUERY_GRAMMAR_FILTER_CASES, QUERY_GRAMMAR_PAGING_CASES, QUERY_GRAMMAR_REFUSED, changeLogStoreCases, commandStreamStoreCases, dataAccessGroupState, idempotencyStoreCases, runChangeLogStoreConformance, runCommandStreamStoreConformance, runDataAccessConformance, runIdempotencyStoreConformance, runLockAdapterConformance, runMergeKeysetConformance, runPaginationPropertyConformance, runPurgeConformance, runQueryGrammarConformance, runReadModelPropertyConformance, runStandardRepoConformance, runUsageStoreContract };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { RepoCapabilities } from "../repository/capabilities.mjs";
|
|
2
|
+
import { KeysetSource } from "../pagination/merge-keyset.mjs";
|
|
3
|
+
//#region src/testing/merge-keyset-conformance.d.ts
|
|
4
|
+
interface MergeKeysetFixture {
|
|
5
|
+
/** Insert into source 0 or 1; `k` is a small integer sort key (ties expected), `label` unique. */
|
|
6
|
+
insert(source: 0 | 1, rows: ReadonlyArray<{
|
|
7
|
+
k: number;
|
|
8
|
+
label: string;
|
|
9
|
+
}>): Promise<void>;
|
|
10
|
+
/** Fresh sources over the two collections, in source order. */
|
|
11
|
+
sources(): readonly KeysetSource<unknown>[];
|
|
12
|
+
sort: Readonly<Record<string, 1 | -1>>;
|
|
13
|
+
labelOf(row: unknown): string;
|
|
14
|
+
/** The sort key of a row, comparable as a number (larger first: the sort is descending). */
|
|
15
|
+
keyOf(row: unknown): number;
|
|
16
|
+
cleanup(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
interface MergeKeysetHarness {
|
|
19
|
+
name: string;
|
|
20
|
+
features: RepoCapabilities;
|
|
21
|
+
fixture?: () => Promise<MergeKeysetFixture>;
|
|
22
|
+
numRuns?: number;
|
|
23
|
+
}
|
|
24
|
+
declare function runMergeKeysetConformance(harness: MergeKeysetHarness): void;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { MergeKeysetFixture, MergeKeysetHarness, runMergeKeysetConformance };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mergeKeysetPages } from "../pagination/merge-keyset.mjs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import fc from "fast-check";
|
|
4
|
+
//#region src/testing/merge-keyset-conformance.ts
|
|
5
|
+
/**
|
|
6
|
+
* `mergeKeysetPages` over a kit's real sources (fast-check), gated by
|
|
7
|
+
* `RepoCapabilities.mergeKeyset`: two sources ordered by the same total order (descending key),
|
|
8
|
+
* merged to the end, equal the sorted union with every row once, also when rows are inserted
|
|
9
|
+
* between pages. An undeclared capability is a skipped group naming it.
|
|
10
|
+
*/
|
|
11
|
+
function runMergeKeysetConformance(harness) {
|
|
12
|
+
const title = `[merge keyset] ${harness.name}`;
|
|
13
|
+
if (harness.features.mergeKeyset !== true) {
|
|
14
|
+
describe.skip(`${title} [skipped: capability 'mergeKeyset' not declared]`, () => {
|
|
15
|
+
it("skipped", () => void 0);
|
|
16
|
+
});
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
describe(title, () => {
|
|
20
|
+
if (!harness.fixture) {
|
|
21
|
+
it("declares 'mergeKeyset' and supplies its fixture", () => {
|
|
22
|
+
throw new Error(`${harness.name} declares 'mergeKeyset' but supplies no fixture`);
|
|
23
|
+
});
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const make = harness.fixture;
|
|
27
|
+
it("a merged walk equals the sorted union, each row once, with inserts between pages", async () => {
|
|
28
|
+
await fc.assert(fc.asyncProperty(fc.array(fc.integer({
|
|
29
|
+
min: 0,
|
|
30
|
+
max: 5
|
|
31
|
+
}), { maxLength: 12 }), fc.array(fc.integer({
|
|
32
|
+
min: 0,
|
|
33
|
+
max: 5
|
|
34
|
+
}), { maxLength: 12 }), fc.integer({
|
|
35
|
+
min: 1,
|
|
36
|
+
max: 5
|
|
37
|
+
}), fc.array(fc.tuple(fc.constantFrom(0, 1), fc.integer({
|
|
38
|
+
min: 0,
|
|
39
|
+
max: 5
|
|
40
|
+
})), { maxLength: 4 }), async (a, b, limit, inserts) => {
|
|
41
|
+
const f = await make();
|
|
42
|
+
try {
|
|
43
|
+
await f.insert(0, a.map((k, i) => ({
|
|
44
|
+
k,
|
|
45
|
+
label: `a${i}`
|
|
46
|
+
})));
|
|
47
|
+
await f.insert(1, b.map((k, i) => ({
|
|
48
|
+
k,
|
|
49
|
+
label: `b${i}`
|
|
50
|
+
})));
|
|
51
|
+
const seen = [];
|
|
52
|
+
let cursor;
|
|
53
|
+
for (let step = 0; step < 200; step++) {
|
|
54
|
+
const page = await mergeKeysetPages(f.sources(), {
|
|
55
|
+
sort: f.sort,
|
|
56
|
+
limit,
|
|
57
|
+
...cursor ? { cursor } : {}
|
|
58
|
+
});
|
|
59
|
+
seen.push(...page.docs.map((d) => ({
|
|
60
|
+
label: f.labelOf(d.row),
|
|
61
|
+
key: f.keyOf(d.row)
|
|
62
|
+
})));
|
|
63
|
+
const ins = inserts[step];
|
|
64
|
+
if (ins) await f.insert(ins[0], [{
|
|
65
|
+
k: ins[1],
|
|
66
|
+
label: `z${step}`
|
|
67
|
+
}]);
|
|
68
|
+
if (!page.hasNext) break;
|
|
69
|
+
cursor = page.next ?? void 0;
|
|
70
|
+
}
|
|
71
|
+
const labels = seen.map((s) => s.label);
|
|
72
|
+
expect(new Set(labels).size).toBe(labels.length);
|
|
73
|
+
for (let i = 0; i < a.length; i++) expect(labels).toContain(`a${i}`);
|
|
74
|
+
for (let i = 0; i < b.length; i++) expect(labels).toContain(`b${i}`);
|
|
75
|
+
const keys = seen.map((s) => s.key);
|
|
76
|
+
expect(keys).toEqual([...keys].sort((x, y) => y - x));
|
|
77
|
+
} finally {
|
|
78
|
+
await f.cleanup();
|
|
79
|
+
}
|
|
80
|
+
}), { numRuns: harness.numRuns ?? 15 });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
export { runMergeKeysetConformance };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { RepoCapabilities } from "../repository/capabilities.mjs";
|
|
2
|
+
//#region src/testing/pagination-property-conformance.d.ts
|
|
3
|
+
interface PropertyRow {
|
|
4
|
+
id: string;
|
|
5
|
+
k: number | null;
|
|
6
|
+
s: string;
|
|
7
|
+
}
|
|
8
|
+
interface PaginationPropertyFixture {
|
|
9
|
+
/** Insert rows (`k` may be null); returns their ids in order. */
|
|
10
|
+
insert(rows: ReadonlyArray<{
|
|
11
|
+
k: number | null;
|
|
12
|
+
s: string;
|
|
13
|
+
}>): Promise<string[]>;
|
|
14
|
+
update(id: string, k: number | null): Promise<void>;
|
|
15
|
+
remove(id: string): Promise<void>;
|
|
16
|
+
keysetPage(params: {
|
|
17
|
+
sort: Record<string, 1 | -1>;
|
|
18
|
+
limit: number;
|
|
19
|
+
after?: string;
|
|
20
|
+
}): Promise<{
|
|
21
|
+
data: PropertyRow[];
|
|
22
|
+
next: string | null;
|
|
23
|
+
}>;
|
|
24
|
+
offsetPage(params: {
|
|
25
|
+
sort: Record<string, 1 | -1>;
|
|
26
|
+
page: number;
|
|
27
|
+
limit: number;
|
|
28
|
+
}): Promise<{
|
|
29
|
+
data: PropertyRow[];
|
|
30
|
+
}>;
|
|
31
|
+
/** Remove every row (called between property runs). */
|
|
32
|
+
reset(): Promise<void>;
|
|
33
|
+
cleanup(): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
interface PaginationPropertyHarness {
|
|
36
|
+
name: string;
|
|
37
|
+
features: RepoCapabilities;
|
|
38
|
+
fixture?: () => Promise<PaginationPropertyFixture>;
|
|
39
|
+
/** fast-check runs per property. Default 15. */
|
|
40
|
+
numRuns?: number;
|
|
41
|
+
}
|
|
42
|
+
declare function runPaginationPropertyConformance(harness: PaginationPropertyHarness): void;
|
|
43
|
+
//#endregion
|
|
44
|
+
export { PaginationPropertyFixture, PaginationPropertyHarness, PropertyRow, runPaginationPropertyConformance };
|