@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +1336 -1263
  2. package/dist/errors/codes.d.mts +10 -1
  3. package/dist/errors/codes.mjs +9 -1
  4. package/dist/errors/index.d.mts +2 -2
  5. package/dist/errors/index.mjs +2 -2
  6. package/dist/pagination/index.d.mts +3 -1
  7. package/dist/pagination/index.mjs +3 -1
  8. package/dist/pagination/merge-keyset.d.mts +50 -0
  9. package/dist/pagination/merge-keyset.mjs +139 -0
  10. package/dist/query-parser/grammar.d.mts +6 -0
  11. package/dist/query-parser/grammar.mjs +21 -4
  12. package/dist/query-parser/reserved.mjs +2 -0
  13. package/dist/repository/bulk-upsert.d.mts +59 -0
  14. package/dist/repository/bulk-upsert.mjs +50 -0
  15. package/dist/repository/capabilities.d.mts +23 -0
  16. package/dist/repository/index.d.mts +2 -1
  17. package/dist/repository/index.mjs +2 -1
  18. package/dist/repository/types.d.mts +7 -1
  19. package/dist/sync/index.d.mts +90 -55
  20. package/dist/sync/index.mjs +0 -0
  21. package/dist/testing/change-log-conformance.d.mts +21 -0
  22. package/dist/testing/change-log-conformance.mjs +204 -0
  23. package/dist/testing/command-stream-conformance.d.mts +15 -0
  24. package/dist/testing/command-stream-conformance.mjs +163 -0
  25. package/dist/testing/data-access-conformance.d.mts +89 -0
  26. package/dist/testing/data-access-conformance.mjs +331 -0
  27. package/dist/testing/index.d.mts +7 -1
  28. package/dist/testing/index.mjs +7 -1
  29. package/dist/testing/merge-keyset-conformance.d.mts +26 -0
  30. package/dist/testing/merge-keyset-conformance.mjs +85 -0
  31. package/dist/testing/pagination-property-conformance.d.mts +44 -0
  32. package/dist/testing/pagination-property-conformance.mjs +160 -0
  33. package/dist/testing/read-model-property-conformance.d.mts +13 -0
  34. package/dist/testing/read-model-property-conformance.mjs +74 -0
  35. 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.29.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,