@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,160 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import fc from "fast-check";
|
|
3
|
+
//#region src/testing/pagination-property-conformance.ts
|
|
4
|
+
/**
|
|
5
|
+
* Pagination properties (fast-check), gated by `RepoCapabilities.paginationProperties`.
|
|
6
|
+
*
|
|
7
|
+
* - a full keyset walk equals an in-memory sort of the same rows (nulls first ascending, last
|
|
8
|
+
* descending; ties broken by id), for a numeric-nullable and a string key;
|
|
9
|
+
* - under inserts, deletes and sort-key updates between pages, every row untouched during the
|
|
10
|
+
* walk is returned exactly once (no duplicate, no gap). A row whose key MOVED may be met
|
|
11
|
+
* twice or not at all; no keyset scheme can promise more for a row that changed position;
|
|
12
|
+
* - an offset page never repeats a row.
|
|
13
|
+
* Requires `fast-check` in the consumer's devDependencies (like `vitest`). An undeclared
|
|
14
|
+
* capability is reported as a skipped group naming it; a declared one without a fixture fails.
|
|
15
|
+
*/
|
|
16
|
+
const rowArb = fc.record({
|
|
17
|
+
k: fc.option(fc.integer({
|
|
18
|
+
min: -5,
|
|
19
|
+
max: 5
|
|
20
|
+
}), { nil: null }),
|
|
21
|
+
s: fc.constantFrom("a", "b", "c", "aa", "ab")
|
|
22
|
+
});
|
|
23
|
+
function compare(a, b, field, dir) {
|
|
24
|
+
const av = a[field];
|
|
25
|
+
const bv = b[field];
|
|
26
|
+
return (av === bv ? 0 : av === null ? -1 : bv === null ? 1 : av < bv ? -1 : 1) * dir;
|
|
27
|
+
}
|
|
28
|
+
async function walkKeyset(f, sort, limit, between) {
|
|
29
|
+
const out = [];
|
|
30
|
+
let after;
|
|
31
|
+
for (let step = 0; step < 500; step++) {
|
|
32
|
+
const page = await f.keysetPage({
|
|
33
|
+
sort,
|
|
34
|
+
limit,
|
|
35
|
+
...after ? { after } : {}
|
|
36
|
+
});
|
|
37
|
+
out.push(...page.data);
|
|
38
|
+
if (!page.next) return out;
|
|
39
|
+
after = page.next;
|
|
40
|
+
if (between) await between(step);
|
|
41
|
+
}
|
|
42
|
+
throw new Error("keyset walk did not terminate in 500 pages");
|
|
43
|
+
}
|
|
44
|
+
function runPaginationPropertyConformance(harness) {
|
|
45
|
+
const title = `[pagination properties] ${harness.name}`;
|
|
46
|
+
if (harness.features.paginationProperties !== true) {
|
|
47
|
+
describe.skip(`${title} [skipped: capability 'paginationProperties' not declared]`, () => {
|
|
48
|
+
it("skipped", () => void 0);
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const numRuns = harness.numRuns ?? 15;
|
|
53
|
+
describe(title, () => {
|
|
54
|
+
if (!harness.fixture) {
|
|
55
|
+
it("declares 'paginationProperties' and supplies its fixture", () => {
|
|
56
|
+
throw new Error(`${harness.name} declares 'paginationProperties' but supplies no fixture`);
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const make = harness.fixture;
|
|
61
|
+
it("a full keyset walk equals an in-memory sort (nulls, ties)", async () => {
|
|
62
|
+
const f = await make();
|
|
63
|
+
try {
|
|
64
|
+
await fc.assert(fc.asyncProperty(fc.array(rowArb, {
|
|
65
|
+
minLength: 0,
|
|
66
|
+
maxLength: 25
|
|
67
|
+
}), fc.constantFrom("k", "s"), fc.constantFrom(1, -1), fc.integer({
|
|
68
|
+
min: 1,
|
|
69
|
+
max: 6
|
|
70
|
+
}), async (rows, field, dir, limit) => {
|
|
71
|
+
await f.reset();
|
|
72
|
+
await f.insert(rows);
|
|
73
|
+
const walked = await walkKeyset(f, { [field]: dir }, limit);
|
|
74
|
+
expect(new Set(walked.map((r) => r.id)).size).toBe(rows.length);
|
|
75
|
+
const keys = walked.map((r) => r[field]);
|
|
76
|
+
const sorted = [...walked].sort((a, b) => compare(a, b, field, dir)).map((r) => r[field]);
|
|
77
|
+
expect(keys).toEqual(sorted);
|
|
78
|
+
}), { numRuns });
|
|
79
|
+
} finally {
|
|
80
|
+
await f.cleanup();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
it("a keyset walk under concurrent writes returns every untouched row exactly once", async () => {
|
|
84
|
+
const f = await make();
|
|
85
|
+
try {
|
|
86
|
+
await fc.assert(fc.asyncProperty(fc.array(rowArb, {
|
|
87
|
+
minLength: 5,
|
|
88
|
+
maxLength: 25
|
|
89
|
+
}), fc.array(fc.oneof(fc.record({
|
|
90
|
+
op: fc.constant("insert"),
|
|
91
|
+
row: rowArb
|
|
92
|
+
}), fc.record({
|
|
93
|
+
op: fc.constant("delete"),
|
|
94
|
+
at: fc.nat()
|
|
95
|
+
}), fc.record({
|
|
96
|
+
op: fc.constant("move"),
|
|
97
|
+
at: fc.nat(),
|
|
98
|
+
k: fc.option(fc.integer({
|
|
99
|
+
min: -5,
|
|
100
|
+
max: 5
|
|
101
|
+
}), { nil: null })
|
|
102
|
+
})), { maxLength: 12 }), fc.constantFrom(1, -1), async (rows, ops, dir) => {
|
|
103
|
+
await f.reset();
|
|
104
|
+
const ids = await f.insert(rows);
|
|
105
|
+
const touched = /* @__PURE__ */ new Set();
|
|
106
|
+
const live = [...ids];
|
|
107
|
+
const seen = (await walkKeyset(f, { k: dir }, 3, async (step) => {
|
|
108
|
+
const op = ops[step];
|
|
109
|
+
if (!op) return;
|
|
110
|
+
if (op.op === "insert") {
|
|
111
|
+
const [id] = await f.insert([op.row]);
|
|
112
|
+
if (id) touched.add(id);
|
|
113
|
+
} else if (live.length > 0) {
|
|
114
|
+
const id = live[op.at % live.length];
|
|
115
|
+
touched.add(id);
|
|
116
|
+
if (op.op === "delete") {
|
|
117
|
+
live.splice(live.indexOf(id), 1);
|
|
118
|
+
await f.remove(id);
|
|
119
|
+
} else await f.update(id, op.k);
|
|
120
|
+
}
|
|
121
|
+
})).map((r) => r.id);
|
|
122
|
+
for (const id of ids) {
|
|
123
|
+
if (touched.has(id)) continue;
|
|
124
|
+
expect(seen.filter((x) => x === id), `untouched row ${id}`).toHaveLength(1);
|
|
125
|
+
}
|
|
126
|
+
}), { numRuns });
|
|
127
|
+
} finally {
|
|
128
|
+
await f.cleanup();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
it("an offset page never repeats a row", async () => {
|
|
132
|
+
const f = await make();
|
|
133
|
+
try {
|
|
134
|
+
await fc.assert(fc.asyncProperty(fc.array(rowArb, {
|
|
135
|
+
minLength: 1,
|
|
136
|
+
maxLength: 25
|
|
137
|
+
}), fc.integer({
|
|
138
|
+
min: 1,
|
|
139
|
+
max: 6
|
|
140
|
+
}), fc.integer({
|
|
141
|
+
min: 1,
|
|
142
|
+
max: 5
|
|
143
|
+
}), async (rows, limit, page) => {
|
|
144
|
+
await f.reset();
|
|
145
|
+
await f.insert(rows);
|
|
146
|
+
const { data } = await f.offsetPage({
|
|
147
|
+
sort: { s: 1 },
|
|
148
|
+
page,
|
|
149
|
+
limit
|
|
150
|
+
});
|
|
151
|
+
expect(new Set(data.map((r) => r.id)).size).toBe(data.length);
|
|
152
|
+
}), { numRuns });
|
|
153
|
+
} finally {
|
|
154
|
+
await f.cleanup();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
export { runPaginationPropertyConformance };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { RepoCapabilities } from "../repository/capabilities.mjs";
|
|
2
|
+
import { ReadModelFixture } from "./data-access-conformance.mjs";
|
|
3
|
+
//#region src/testing/read-model-property-conformance.d.ts
|
|
4
|
+
interface ReadModelPropertyHarness {
|
|
5
|
+
name: string;
|
|
6
|
+
features: RepoCapabilities;
|
|
7
|
+
fixture?: () => Promise<ReadModelFixture>;
|
|
8
|
+
/** fast-check runs. Default 15. */
|
|
9
|
+
numRuns?: number;
|
|
10
|
+
}
|
|
11
|
+
declare function runReadModelPropertyConformance(harness: ReadModelPropertyHarness): void;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { ReadModelPropertyHarness, runReadModelPropertyConformance };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import fc from "fast-check";
|
|
3
|
+
//#region src/testing/read-model-property-conformance.ts
|
|
4
|
+
/**
|
|
5
|
+
* Read-model properties (fast-check), gated by `RepoCapabilities.readModel`.
|
|
6
|
+
*
|
|
7
|
+
* For random increments with random replays: the incremental model equals the source (no drift,
|
|
8
|
+
* a replay never double-counts); a rebuild from the source keeps zero drift; and corrupting one
|
|
9
|
+
* grain yields EXACTLY one drift row, naming that grain. An undeclared capability is a skipped
|
|
10
|
+
* group naming it; a declared one without a fixture fails.
|
|
11
|
+
*/
|
|
12
|
+
const eventArb = fc.record({
|
|
13
|
+
period: fc.constantFrom("2026-01", "2026-02", "2026-03"),
|
|
14
|
+
rate: fc.constantFrom(0, 5, 15),
|
|
15
|
+
tax: fc.integer({
|
|
16
|
+
min: 1,
|
|
17
|
+
max: 1e3
|
|
18
|
+
})
|
|
19
|
+
});
|
|
20
|
+
function runReadModelPropertyConformance(harness) {
|
|
21
|
+
const title = `[read-model properties] ${harness.name}`;
|
|
22
|
+
if (harness.features.readModel !== true) {
|
|
23
|
+
describe.skip(`${title} [skipped: capability 'readModel' not declared]`, () => {
|
|
24
|
+
it("skipped", () => void 0);
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
describe(title, () => {
|
|
29
|
+
if (!harness.fixture) {
|
|
30
|
+
it("declares 'readModel' and supplies its fixture", () => {
|
|
31
|
+
throw new Error(`${harness.name} declares 'readModel' but supplies no fixture`);
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const make = harness.fixture;
|
|
36
|
+
it("increments with replays equal the source; rebuild keeps zero drift; one corruption is one drift row", async () => {
|
|
37
|
+
await fc.assert(fc.asyncProperty(fc.array(eventArb, {
|
|
38
|
+
minLength: 1,
|
|
39
|
+
maxLength: 20
|
|
40
|
+
}), fc.array(fc.nat(), { maxLength: 10 }), fc.nat(), async (events, replayPicks, corruptPick) => {
|
|
41
|
+
const f = await make();
|
|
42
|
+
try {
|
|
43
|
+
const tenant = "t-prop";
|
|
44
|
+
const keyed = events.map((e, i) => ({
|
|
45
|
+
...e,
|
|
46
|
+
dedupeKey: `e${i}`
|
|
47
|
+
}));
|
|
48
|
+
await f.seedSource(tenant, events);
|
|
49
|
+
const replays = replayPicks.map((p) => keyed[p % keyed.length]).filter((x) => x !== void 0);
|
|
50
|
+
const half = Math.ceil(keyed.length / 2);
|
|
51
|
+
await f.applyIncrements(tenant, keyed.slice(0, half));
|
|
52
|
+
await f.applyIncrements(tenant, [...replays, ...keyed.slice(half)]);
|
|
53
|
+
expect(await f.reconcile(tenant)).toEqual([]);
|
|
54
|
+
await f.rebuild(tenant);
|
|
55
|
+
expect(await f.reconcile(tenant)).toEqual([]);
|
|
56
|
+
const grains = await f.readTotals(tenant);
|
|
57
|
+
const target = grains[corruptPick % grains.length];
|
|
58
|
+
if (!target) throw new Error("no grain to corrupt");
|
|
59
|
+
await f.tamper(tenant, target.period, target.rate);
|
|
60
|
+
const drift = await f.reconcile(tenant);
|
|
61
|
+
expect(drift).toHaveLength(1);
|
|
62
|
+
expect(drift[0]?.key).toMatchObject({
|
|
63
|
+
period: target.period,
|
|
64
|
+
rate: target.rate
|
|
65
|
+
});
|
|
66
|
+
} finally {
|
|
67
|
+
await f.cleanup();
|
|
68
|
+
}
|
|
69
|
+
}), { numRuns: harness.numRuns ?? 15 });
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
export { runReadModelPropertyConformance };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classytic/repo-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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 \u2014 no plugins ship here; each kit owns its own.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|