@classytic/repo-core 0.1.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 (84) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -0
  4. package/dist/cache/index.d.mts +4 -0
  5. package/dist/cache/index.mjs +3 -0
  6. package/dist/cache/memory-adapter.d.mts +7 -0
  7. package/dist/cache/memory-adapter.mjs +37 -0
  8. package/dist/cache/stable-stringify.d.mts +15 -0
  9. package/dist/cache/stable-stringify.mjs +19 -0
  10. package/dist/cache/types.d.mts +59 -0
  11. package/dist/context/index.d.mts +2 -0
  12. package/dist/context/index.mjs +0 -0
  13. package/dist/context/types.d.mts +24 -0
  14. package/dist/errors/create-error.d.mts +19 -0
  15. package/dist/errors/create-error.mjs +23 -0
  16. package/dist/errors/duplicate-key.d.mts +38 -0
  17. package/dist/errors/duplicate-key.mjs +57 -0
  18. package/dist/errors/index.d.mts +4 -0
  19. package/dist/errors/index.mjs +3 -0
  20. package/dist/errors/types.d.mts +37 -0
  21. package/dist/filter/builders.d.mts +60 -0
  22. package/dist/filter/builders.mjs +172 -0
  23. package/dist/filter/guard.d.mts +13 -0
  24. package/dist/filter/guard.mjs +34 -0
  25. package/dist/filter/index.d.mts +7 -0
  26. package/dist/filter/index.mjs +6 -0
  27. package/dist/filter/match.d.mts +12 -0
  28. package/dist/filter/match.mjs +91 -0
  29. package/dist/filter/scope.d.mts +31 -0
  30. package/dist/filter/scope.mjs +54 -0
  31. package/dist/filter/types.d.mts +143 -0
  32. package/dist/filter/walk.d.mts +24 -0
  33. package/dist/filter/walk.mjs +77 -0
  34. package/dist/hooks/engine.d.mts +48 -0
  35. package/dist/hooks/engine.mjs +101 -0
  36. package/dist/hooks/events.d.mts +95 -0
  37. package/dist/hooks/events.mjs +93 -0
  38. package/dist/hooks/index.d.mts +5 -0
  39. package/dist/hooks/index.mjs +4 -0
  40. package/dist/hooks/priority.d.mts +23 -0
  41. package/dist/hooks/priority.mjs +21 -0
  42. package/dist/hooks/types.d.mts +37 -0
  43. package/dist/lookup/index.d.mts +2 -0
  44. package/dist/lookup/index.mjs +0 -0
  45. package/dist/lookup/types.d.mts +170 -0
  46. package/dist/operations/index.d.mts +3 -0
  47. package/dist/operations/index.mjs +2 -0
  48. package/dist/operations/registry.d.mts +41 -0
  49. package/dist/operations/registry.mjs +140 -0
  50. package/dist/operations/types.d.mts +49 -0
  51. package/dist/pagination/cursor.d.mts +44 -0
  52. package/dist/pagination/cursor.mjs +150 -0
  53. package/dist/pagination/index.d.mts +5 -0
  54. package/dist/pagination/index.mjs +4 -0
  55. package/dist/pagination/keyset.d.mts +25 -0
  56. package/dist/pagination/keyset.mjs +61 -0
  57. package/dist/pagination/offset.d.mts +26 -0
  58. package/dist/pagination/offset.mjs +47 -0
  59. package/dist/pagination/types.d.mts +136 -0
  60. package/dist/query-parser/coerce.d.mts +16 -0
  61. package/dist/query-parser/coerce.mjs +73 -0
  62. package/dist/query-parser/index.d.mts +4 -0
  63. package/dist/query-parser/index.mjs +3 -0
  64. package/dist/query-parser/parse-url.d.mts +7 -0
  65. package/dist/query-parser/parse-url.mjs +224 -0
  66. package/dist/query-parser/types.d.mts +104 -0
  67. package/dist/repository/base.d.mts +90 -0
  68. package/dist/repository/base.mjs +111 -0
  69. package/dist/repository/index.d.mts +5 -0
  70. package/dist/repository/index.mjs +3 -0
  71. package/dist/repository/plugin-types.d.mts +27 -0
  72. package/dist/repository/plugin-types.mjs +45 -0
  73. package/dist/repository/types.d.mts +470 -0
  74. package/dist/schema/field-rules.d.mts +62 -0
  75. package/dist/schema/field-rules.mjs +110 -0
  76. package/dist/schema/index.d.mts +3 -0
  77. package/dist/schema/index.mjs +2 -0
  78. package/dist/schema/types.d.mts +138 -0
  79. package/dist/testing/conformance.d.mts +6 -0
  80. package/dist/testing/conformance.mjs +481 -0
  81. package/dist/testing/index.d.mts +3 -0
  82. package/dist/testing/index.mjs +2 -0
  83. package/dist/testing/types.d.mts +113 -0
  84. package/package.json +130 -0
@@ -0,0 +1,138 @@
1
+ //#region src/schema/types.d.ts
2
+ /**
3
+ * Schema contract types — shared across every kit that emits JSON Schemas
4
+ * from its native schema (Mongoose models, Drizzle tables, Prisma schemas,
5
+ * zod schemas, etc.).
6
+ *
7
+ * Driver-free by design: every field on every type describes the *output*
8
+ * shape, not how it's produced. A kit's introspection code (mongooseToJsonSchema,
9
+ * drizzleToJsonSchema, ...) fills in the shape; repo-core just locks the
10
+ * contract so switching kits is a package swap, not an API rewrite.
11
+ */
12
+ /**
13
+ * Per-field rule — declarative constraints that shape how a field appears
14
+ * in the create / update / query body schemas.
15
+ *
16
+ * These are pure policy: they touch neither the DB schema nor the Filter IR.
17
+ * A kit's schema builder reads them and omits / optionalizes fields in the
18
+ * generated JSON Schema accordingly.
19
+ */
20
+ interface FieldRule {
21
+ /** Field cannot be updated — omitted from the update body schema. */
22
+ immutable?: boolean;
23
+ /** Alias for `immutable`. Kept for docstring clarity at call sites. */
24
+ immutableAfterCreate?: boolean;
25
+ /** System-only field — omitted from both create AND update body schemas. */
26
+ systemManaged?: boolean;
27
+ /** Remove from `required[]` in the generated schema. DB-level constraints unaffected. */
28
+ optional?: boolean;
29
+ }
30
+ /** Map of field name → FieldRule. */
31
+ interface FieldRules {
32
+ [fieldName: string]: FieldRule;
33
+ }
34
+ /**
35
+ * JSON Schema (draft-07 subset) — intentionally loose so kits can emit
36
+ * vendor extensions (`x-ref`, `x-foreign-key`, etc.) without type pressure.
37
+ */
38
+ interface JsonSchema {
39
+ type: string | string[];
40
+ properties?: Record<string, unknown>;
41
+ required?: string[];
42
+ additionalProperties?: boolean | unknown;
43
+ items?: unknown;
44
+ enum?: unknown[];
45
+ format?: string;
46
+ pattern?: string;
47
+ minProperties?: number;
48
+ maxProperties?: number;
49
+ minLength?: number;
50
+ maxLength?: number;
51
+ minimum?: number;
52
+ maximum?: number;
53
+ default?: unknown;
54
+ description?: string;
55
+ title?: string;
56
+ [key: `x-${string}`]: unknown;
57
+ }
58
+ /**
59
+ * CRUD schema bundle — the four JSON Schemas every HTTP endpoint needs:
60
+ * body validation on POST / PATCH, route-param validation on id routes, and
61
+ * query-string validation on list endpoints.
62
+ */
63
+ interface CrudSchemas {
64
+ /** JSON Schema for create request body (POST). */
65
+ createBody: JsonSchema;
66
+ /** JSON Schema for update request body (PATCH / PUT). */
67
+ updateBody: JsonSchema;
68
+ /** JSON Schema for route params (id validation). */
69
+ params: JsonSchema;
70
+ /** JSON Schema for list/query parameters. */
71
+ listQuery: JsonSchema;
72
+ }
73
+ /**
74
+ * Options consumed by every kit's schema builder. Fields are additive:
75
+ * kit-specific extensions live on a separate extending interface so the
76
+ * portable subset stays identical across kits.
77
+ */
78
+ interface SchemaBuilderOptions {
79
+ /** Field rules for create/update schemas. */
80
+ fieldRules?: FieldRules;
81
+ /**
82
+ * When `true`, emit `"additionalProperties": false` on create/update/query
83
+ * schemas. Default `false` so generators stay permissive by default;
84
+ * Fastify/AJV consumers typically flip this on for stricter validation.
85
+ */
86
+ strictAdditionalProperties?: boolean;
87
+ /** Date rendering: `'datetime'` → `format: date-time`; `'date'` → `format: date`. */
88
+ dateAs?: 'date' | 'datetime';
89
+ /** Create-schema overrides. */
90
+ create?: {
91
+ /** Fields to omit from the create body. */omitFields?: string[]; /** Force field to required (merged with auto-detected required). */
92
+ requiredOverrides?: Record<string, boolean>; /** Force field to optional (even if DB-level required). */
93
+ optionalOverrides?: Record<string, boolean>; /** Replace the generated schema for a specific field. */
94
+ schemaOverrides?: Record<string, unknown>;
95
+ };
96
+ /**
97
+ * Field names to mark as soft-required: they remain in the generated body
98
+ * schema's `properties` (still validated when present) but are excluded
99
+ * from the `required[]` array so the client may omit them.
100
+ *
101
+ * DB-level `required: true` invariants are unaffected — the driver still
102
+ * rejects null on save. This flag only affects HTTP body validation.
103
+ */
104
+ softRequiredFields?: string[];
105
+ /** Update-schema overrides. */
106
+ update?: {
107
+ /** Fields to omit from the update body. */omitFields?: string[]; /** When `true`, reject empty update bodies (`minProperties: 1`). */
108
+ requireAtLeastOne?: boolean;
109
+ };
110
+ /** List-query schema overrides. */
111
+ query?: {
112
+ /** Extra filterable fields exposed on the list-query schema. */filterableFields?: Record<string, {
113
+ type: string;
114
+ } | unknown>;
115
+ };
116
+ /**
117
+ * Emit OpenAPI vendor extensions (`x-*` keywords like `x-ref` for populated
118
+ * foreign-key fields).
119
+ *
120
+ * Default `false` because Ajv strict mode throws on unknown `x-*` keywords.
121
+ * Turn ON when feeding the schema into a docgen tool (Swagger, Redocly).
122
+ */
123
+ openApiExtensions?: boolean;
124
+ }
125
+ /**
126
+ * Result of `validateUpdateBody` — caller-friendly shape with structured
127
+ * violations for each disallowed field.
128
+ */
129
+ interface ValidationResult {
130
+ valid: boolean;
131
+ violations?: Array<{
132
+ field: string;
133
+ reason: string;
134
+ }>;
135
+ message?: string;
136
+ }
137
+ //#endregion
138
+ export { CrudSchemas, FieldRule, FieldRules, JsonSchema, SchemaBuilderOptions, ValidationResult };
@@ -0,0 +1,6 @@
1
+ import { ConformanceDoc, ConformanceHarness } from "./types.mjs";
2
+
3
+ //#region src/testing/conformance.d.ts
4
+ declare function runStandardRepoConformance<TDoc extends ConformanceDoc = ConformanceDoc>(harness: ConformanceHarness<TDoc>): void;
5
+ //#endregion
6
+ export { runStandardRepoConformance };
@@ -0,0 +1,481 @@
1
+ import { and, eq, gt, in_, isNull, like, ne, or } from "../filter/builders.mjs";
2
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
+ //#region src/testing/conformance.ts
4
+ /**
5
+ * `runStandardRepoConformance` — the cross-kit contract suite.
6
+ *
7
+ * Wires a kit-specific `ConformanceHarness` to a canonical set of
8
+ * scenarios that every `StandardRepo<TDoc>` implementation should pass.
9
+ * Each describe block probes one behavior of the contract; scenarios
10
+ * the backend doesn't support (D1 transactions, Mongo standalone
11
+ * transactions, optional methods) are `it.skip`ped via feature flags.
12
+ *
13
+ * The goal is to make "swap mongokit for sqlitekit" a provable claim:
14
+ * when both kits' conformance suites stay green, controller code can
15
+ * move between backends without behavior drift.
16
+ */
17
+ /** Read the primary key from a doc regardless of backend convention. */
18
+ function idOf(doc, idField) {
19
+ if (!doc) return void 0;
20
+ const value = doc[idField];
21
+ return value == null ? void 0 : String(value);
22
+ }
23
+ /** ISO timestamp N seconds offset from now — deterministic ordering fixture. */
24
+ function isoAt(offsetSeconds) {
25
+ return new Date(Date.UTC(2026, 3, 1) + offsetSeconds * 1e3).toISOString();
26
+ }
27
+ function runStandardRepoConformance(harness) {
28
+ describe(`[conformance] ${harness.name}`, () => {
29
+ let ctx;
30
+ beforeEach(async () => {
31
+ ctx = await harness.setup();
32
+ });
33
+ afterEach(async () => {
34
+ await ctx.cleanup();
35
+ });
36
+ describe("CRUD basics", () => {
37
+ it("create → getById round-trips all scalar fields", async () => {
38
+ const input = harness.makeDoc({
39
+ name: "Alice",
40
+ email: "alice@example.com",
41
+ category: "admin",
42
+ count: 42,
43
+ active: true,
44
+ notes: "seed note",
45
+ createdAt: isoAt(0)
46
+ });
47
+ const id = idOf(await ctx.repo.create(input), harness.idField);
48
+ expect(id).toBeDefined();
49
+ const fetched = await ctx.repo.getById(id);
50
+ expect(fetched).not.toBeNull();
51
+ expect(fetched?.name).toBe("Alice");
52
+ expect(fetched?.email).toBe("alice@example.com");
53
+ expect(fetched?.category).toBe("admin");
54
+ expect(fetched?.count).toBe(42);
55
+ expect(fetched?.active).toBe(true);
56
+ expect(fetched?.notes).toBe("seed note");
57
+ expect(fetched?.createdAt).toBe(isoAt(0));
58
+ });
59
+ it("getById miss returns null", async () => {
60
+ expect(await ctx.repo.getById("does-not-exist-xyz")).toBeNull();
61
+ });
62
+ it("update by id returns updated doc; miss returns null", async () => {
63
+ const id = idOf(await ctx.repo.create(harness.makeDoc({
64
+ name: "Bob",
65
+ count: 1
66
+ })), harness.idField);
67
+ expect((await ctx.repo.update(id, { count: 99 }))?.count).toBe(99);
68
+ expect(await ctx.repo.update("no-such-id", { count: 1 })).toBeNull();
69
+ });
70
+ it("delete by id succeeds; second delete returns success:false", async () => {
71
+ const id = idOf(await ctx.repo.create(harness.makeDoc({ name: "Carol" })), harness.idField);
72
+ expect((await ctx.repo.delete(id)).success).toBe(true);
73
+ expect((await ctx.repo.delete(id)).success).toBe(false);
74
+ });
75
+ });
76
+ describe("findOneAndUpdate", () => {
77
+ beforeEach(async () => {
78
+ await ctx.repo.createMany([
79
+ harness.makeDoc({
80
+ name: "u1",
81
+ email: "u1@x.com",
82
+ category: "reader",
83
+ createdAt: isoAt(1)
84
+ }),
85
+ harness.makeDoc({
86
+ name: "u2",
87
+ email: "u2@x.com",
88
+ category: "reader",
89
+ createdAt: isoAt(2)
90
+ }),
91
+ harness.makeDoc({
92
+ name: "u3",
93
+ email: "u3@x.com",
94
+ category: "reader",
95
+ createdAt: isoAt(3)
96
+ })
97
+ ]);
98
+ });
99
+ it("sort claims oldest row first (FIFO)", async () => {
100
+ if (!ctx.repo.findOneAndUpdate) return;
101
+ const claimed = await ctx.repo.findOneAndUpdate({ category: "reader" }, { category: "claimed" }, { sort: { createdAt: 1 } });
102
+ expect(claimed?.name).toBe("u1");
103
+ expect(claimed?.category).toBe("claimed");
104
+ });
105
+ it("returnDocument: \"before\" returns pre-update state", async () => {
106
+ if (!ctx.repo.findOneAndUpdate) return;
107
+ expect((await ctx.repo.findOneAndUpdate({ name: "u2" }, { category: "archived" }, { returnDocument: "before" }))?.category).toBe("reader");
108
+ });
109
+ it("no match, no upsert → returns null", async () => {
110
+ if (!ctx.repo.findOneAndUpdate) return;
111
+ expect(await ctx.repo.findOneAndUpdate({ name: "does-not-exist" }, { category: "x" })).toBeNull();
112
+ });
113
+ it.skipIf(!harness.features.upsert)("upsert inserts when no row matches", async () => {
114
+ if (!ctx.repo.findOneAndUpdate) return;
115
+ const inserted = await ctx.repo.findOneAndUpdate({ email: "brand-new@x.com" }, harness.makeDoc({
116
+ name: "Zed",
117
+ email: "brand-new@x.com",
118
+ category: "reader",
119
+ createdAt: isoAt(100)
120
+ }), { upsert: true });
121
+ expect(inserted?.name).toBe("Zed");
122
+ expect(inserted?.email).toBe("brand-new@x.com");
123
+ });
124
+ });
125
+ describe("updateMany / deleteMany", () => {
126
+ beforeEach(async () => {
127
+ await ctx.repo.createMany([
128
+ harness.makeDoc({
129
+ name: "a",
130
+ category: "reader",
131
+ count: 1
132
+ }),
133
+ harness.makeDoc({
134
+ name: "b",
135
+ category: "reader",
136
+ count: 2
137
+ }),
138
+ harness.makeDoc({
139
+ name: "c",
140
+ category: "admin",
141
+ count: 3
142
+ })
143
+ ]);
144
+ });
145
+ it("updateMany affects only matching rows", async () => {
146
+ if (!ctx.repo.updateMany) return;
147
+ const result = await ctx.repo.updateMany({ category: "reader" }, { category: "former-reader" });
148
+ expect(result.matchedCount).toBe(2);
149
+ expect(result.modifiedCount).toBe(2);
150
+ expect(await ctx.repo.findAll({ category: "admin" })).toHaveLength(1);
151
+ });
152
+ it("updateMany with no match returns matchedCount 0", async () => {
153
+ if (!ctx.repo.updateMany) return;
154
+ const result = await ctx.repo.updateMany({ category: "nonexistent" }, { category: "x" });
155
+ expect(result.matchedCount).toBe(0);
156
+ expect(result.modifiedCount).toBe(0);
157
+ });
158
+ it("deleteMany removes matching rows and reports count", async () => {
159
+ if (!ctx.repo.deleteMany) return;
160
+ expect((await ctx.repo.deleteMany({ category: "reader" }, { mode: "hard" })).deletedCount).toBe(2);
161
+ expect(await ctx.repo.findAll()).toHaveLength(1);
162
+ });
163
+ it("deleteMany with empty match returns deletedCount 0", async () => {
164
+ if (!ctx.repo.deleteMany) return;
165
+ expect((await ctx.repo.deleteMany({ category: "nope" }, { mode: "hard" })).deletedCount).toBe(0);
166
+ });
167
+ });
168
+ describe("projections", () => {
169
+ beforeEach(async () => {
170
+ await ctx.repo.createMany([
171
+ harness.makeDoc({
172
+ name: "a",
173
+ category: "reader"
174
+ }),
175
+ harness.makeDoc({
176
+ name: "b",
177
+ category: "reader"
178
+ }),
179
+ harness.makeDoc({
180
+ name: "c",
181
+ category: "admin"
182
+ }),
183
+ harness.makeDoc({
184
+ name: "d",
185
+ category: null
186
+ }),
187
+ harness.makeDoc({
188
+ name: "e",
189
+ category: null
190
+ })
191
+ ]);
192
+ });
193
+ it.skipIf(!harness.features.distinct)("distinct returns each unique value exactly once", async () => {
194
+ if (!ctx.repo.distinct) return;
195
+ const categories = await ctx.repo.distinct("category");
196
+ const set = new Set(categories);
197
+ expect(set.has("reader")).toBe(true);
198
+ expect(set.has("admin")).toBe(true);
199
+ const nullCount = categories.filter((v) => v === null).length;
200
+ expect(nullCount).toBeLessThanOrEqual(1);
201
+ });
202
+ it.skipIf(!harness.features.countAndExists)("count with filter matches expected rows", async () => {
203
+ if (!ctx.repo.count) return;
204
+ expect(await ctx.repo.count({ category: "reader" })).toBe(2);
205
+ expect(await ctx.repo.count({ category: "no-such" })).toBe(0);
206
+ });
207
+ it.skipIf(!harness.features.countAndExists)("exists is truthy when filter matches, falsy when it does not", async () => {
208
+ if (!ctx.repo.exists) return;
209
+ const hit = await ctx.repo.exists({ category: "reader" });
210
+ const miss = await ctx.repo.exists({ category: "no-such" });
211
+ expect(Boolean(hit)).toBe(true);
212
+ expect(Boolean(miss)).toBe(false);
213
+ });
214
+ });
215
+ describe("aggregate", () => {
216
+ beforeEach(async () => {
217
+ await ctx.repo.createMany([
218
+ harness.makeDoc({
219
+ name: "a",
220
+ category: "reader",
221
+ count: 10,
222
+ active: true
223
+ }),
224
+ harness.makeDoc({
225
+ name: "b",
226
+ category: "reader",
227
+ count: 20,
228
+ active: true
229
+ }),
230
+ harness.makeDoc({
231
+ name: "c",
232
+ category: "admin",
233
+ count: 30,
234
+ active: false
235
+ }),
236
+ harness.makeDoc({
237
+ name: "d",
238
+ category: "admin",
239
+ count: 40,
240
+ active: true
241
+ })
242
+ ]);
243
+ });
244
+ it.skipIf(!harness.features.aggregate)("empty result set returns { rows: [] } (no throw)", async () => {
245
+ if (!ctx.repo.aggregate) return;
246
+ expect((await ctx.repo.aggregate({
247
+ filter: { category: "does-not-exist" },
248
+ groupBy: "category",
249
+ measures: { total: {
250
+ op: "sum",
251
+ field: "count"
252
+ } }
253
+ })).rows).toEqual([]);
254
+ });
255
+ it.skipIf(!harness.features.aggregate)("groupBy + sum produces one row per group with correct totals", async () => {
256
+ if (!ctx.repo.aggregate) return;
257
+ const result = await ctx.repo.aggregate({
258
+ groupBy: "category",
259
+ measures: { total: {
260
+ op: "sum",
261
+ field: "count"
262
+ } },
263
+ sort: { category: 1 }
264
+ });
265
+ expect(result.rows).toHaveLength(2);
266
+ const byCategory = {};
267
+ for (const row of result.rows) byCategory[row.category] = Number(row.total);
268
+ expect(byCategory["admin"]).toBe(70);
269
+ expect(byCategory["reader"]).toBe(30);
270
+ });
271
+ it.skipIf(!harness.features.aggregate)("scalar aggregate (no groupBy) returns single row", async () => {
272
+ if (!ctx.repo.aggregate) return;
273
+ const result = await ctx.repo.aggregate({ measures: {
274
+ total: {
275
+ op: "sum",
276
+ field: "count"
277
+ },
278
+ n: { op: "count" }
279
+ } });
280
+ expect(result.rows).toHaveLength(1);
281
+ expect(Number(result.rows[0]?.total)).toBe(100);
282
+ expect(Number(result.rows[0]?.n)).toBe(4);
283
+ });
284
+ it.skipIf(!harness.features.aggregate)("having filters aggregated rows by measure alias", async () => {
285
+ if (!ctx.repo.aggregate) return;
286
+ const result = await ctx.repo.aggregate({
287
+ groupBy: "category",
288
+ measures: { total: {
289
+ op: "sum",
290
+ field: "count"
291
+ } },
292
+ having: gt("total", 50)
293
+ });
294
+ expect(result.rows).toHaveLength(1);
295
+ expect(result.rows[0].category).toBe("admin");
296
+ });
297
+ });
298
+ describe("pagination edges", () => {
299
+ beforeEach(async () => {
300
+ for (let i = 0; i < 5; i++) await ctx.repo.create(harness.makeDoc({
301
+ name: `p${i}`,
302
+ count: i,
303
+ createdAt: isoAt(i)
304
+ }));
305
+ });
306
+ it("page beyond last returns empty docs array, total reflects real count", async () => {
307
+ const out = await ctx.repo.getAll({
308
+ page: 99,
309
+ limit: 10,
310
+ sort: "createdAt"
311
+ });
312
+ expect(Array.isArray(out.docs)).toBe(true);
313
+ expect(out.docs).toHaveLength(0);
314
+ expect(out.total).toBe(5);
315
+ });
316
+ it("limit larger than dataset returns all rows, no error", async () => {
317
+ const out = await ctx.repo.getAll({
318
+ page: 1,
319
+ limit: 1e3,
320
+ sort: "createdAt"
321
+ });
322
+ expect(out.docs).toHaveLength(5);
323
+ expect(out.total).toBe(5);
324
+ });
325
+ it("explicit filter in pagination narrows results consistently", async () => {
326
+ const out = await ctx.repo.getAll({
327
+ filters: { count: 3 },
328
+ page: 1,
329
+ limit: 10
330
+ });
331
+ expect(out.docs).toHaveLength(1);
332
+ expect(out.total).toBe(1);
333
+ });
334
+ });
335
+ describe("Filter IR compilation parity", () => {
336
+ beforeEach(async () => {
337
+ await ctx.repo.createMany([
338
+ harness.makeDoc({
339
+ name: "plain",
340
+ category: "a",
341
+ count: 1,
342
+ notes: "hello world"
343
+ }),
344
+ harness.makeDoc({
345
+ name: "pct",
346
+ category: "a",
347
+ count: 2,
348
+ notes: "50% off"
349
+ }),
350
+ harness.makeDoc({
351
+ name: "under",
352
+ category: "b",
353
+ count: 3,
354
+ notes: "file_name.txt"
355
+ }),
356
+ harness.makeDoc({
357
+ name: "back",
358
+ category: "b",
359
+ count: 4,
360
+ notes: "back\\slash"
361
+ }),
362
+ harness.makeDoc({
363
+ name: "nullnote",
364
+ category: null,
365
+ count: 5,
366
+ notes: null
367
+ })
368
+ ]);
369
+ });
370
+ it("in_([]) matches nothing (not everything)", async () => {
371
+ expect(await ctx.repo.findAll(in_("category", []))).toHaveLength(0);
372
+ });
373
+ it("in_ with non-empty list matches those values", async () => {
374
+ expect(await ctx.repo.findAll(in_("category", ["a"]))).toHaveLength(2);
375
+ });
376
+ it("eq null matches rows where the field is null", async () => {
377
+ const rows = await ctx.repo.findAll(isNull("category"));
378
+ expect(rows).toHaveLength(1);
379
+ expect(rows[0]?.name).toBe("nullnote");
380
+ });
381
+ it("ne does not include null-valued rows (SQL 3VL / Mongo parity)", async () => {
382
+ expect((await ctx.repo.findAll(ne("category", "a"))).map((r) => r.name).sort()).toEqual(["back", "under"]);
383
+ });
384
+ it("like with % metacharacter in the value matches literally", async () => {
385
+ expect((await ctx.repo.findAll(like("notes", "50\\% off"))).map((r) => r.name)).toEqual(["pct"]);
386
+ });
387
+ it("like with _ metacharacter in the value matches literally", async () => {
388
+ expect((await ctx.repo.findAll(like("notes", "file\\_name.txt"))).map((r) => r.name)).toEqual(["under"]);
389
+ });
390
+ it("nested and/or composes correctly", async () => {
391
+ expect((await ctx.repo.findAll(or(and(eq("category", "a"), gt("count", 1)), eq("name", "back")))).map((r) => r.name).sort()).toEqual(["back", "pct"]);
392
+ });
393
+ });
394
+ describe.skipIf(!harness.features.duplicateKeyError)("isDuplicateKeyError", () => {
395
+ it("returns true for a unique-constraint / E11000 violation", async () => {
396
+ await ctx.repo.create(harness.makeDoc({
397
+ name: "dup",
398
+ email: "dup@x.com"
399
+ }));
400
+ let caught;
401
+ try {
402
+ await ctx.repo.create(harness.makeDoc({
403
+ name: "dup2",
404
+ email: "dup@x.com"
405
+ }));
406
+ } catch (err) {
407
+ caught = err;
408
+ }
409
+ expect(caught).toBeDefined();
410
+ expect(ctx.repo.isDuplicateKeyError?.(caught)).toBe(true);
411
+ });
412
+ it("returns false for unrelated errors", async () => {
413
+ const notDup = /* @__PURE__ */ new Error("something else");
414
+ expect(ctx.repo.isDuplicateKeyError?.(notDup)).toBe(false);
415
+ expect(ctx.repo.isDuplicateKeyError?.(null)).toBe(false);
416
+ expect(ctx.repo.isDuplicateKeyError?.("plain string")).toBe(false);
417
+ });
418
+ });
419
+ describe.skipIf(!harness.features.getOrCreate)("getOrCreate", () => {
420
+ it("inserts when no row matches filter", async () => {
421
+ if (!ctx.repo.getOrCreate) return;
422
+ expect((await ctx.repo.getOrCreate({ email: "fresh@x.com" }, harness.makeDoc({
423
+ name: "Fresh",
424
+ email: "fresh@x.com"
425
+ })))?.name).toBe("Fresh");
426
+ expect(await ctx.repo.findAll()).toHaveLength(1);
427
+ });
428
+ it("returns existing row when filter matches (no insert)", async () => {
429
+ if (!ctx.repo.getOrCreate) return;
430
+ await ctx.repo.create(harness.makeDoc({
431
+ name: "Existing",
432
+ email: "existing@x.com"
433
+ }));
434
+ expect((await ctx.repo.getOrCreate({ email: "existing@x.com" }, harness.makeDoc({
435
+ name: "WouldOverwrite",
436
+ email: "existing@x.com"
437
+ })))?.name).toBe("Existing");
438
+ expect(await ctx.repo.findAll()).toHaveLength(1);
439
+ });
440
+ });
441
+ describe.skipIf(!harness.features.transactions)("withTransaction", () => {
442
+ it("commits when callback resolves", async () => {
443
+ await ctx.repo.withTransaction(async (txRepo) => {
444
+ await txRepo.create(harness.makeDoc({
445
+ name: "tx-committed",
446
+ email: "c@x.com"
447
+ }));
448
+ });
449
+ expect(await ctx.repo.findAll({ name: "tx-committed" })).toHaveLength(1);
450
+ });
451
+ it("rolls back on thrown error — no row persists", async () => {
452
+ const err = /* @__PURE__ */ new Error("boom");
453
+ let caught;
454
+ try {
455
+ await ctx.repo.withTransaction(async (txRepo) => {
456
+ await txRepo.create(harness.makeDoc({
457
+ name: "tx-rollback",
458
+ email: "r@x.com"
459
+ }));
460
+ throw err;
461
+ });
462
+ } catch (e) {
463
+ caught = e;
464
+ }
465
+ expect(caught).toBe(err);
466
+ expect(await ctx.repo.findAll({ name: "tx-rollback" })).toHaveLength(0);
467
+ });
468
+ it("reads inside the txRepo see writes inside the same callback", async () => {
469
+ await ctx.repo.withTransaction(async (txRepo) => {
470
+ const id = idOf(await txRepo.create(harness.makeDoc({
471
+ name: "tx-read",
472
+ email: "rr@x.com"
473
+ })), harness.idField);
474
+ expect((await txRepo.getById(id))?.name).toBe("tx-read");
475
+ });
476
+ });
477
+ });
478
+ });
479
+ }
480
+ //#endregion
481
+ export { runStandardRepoConformance };
@@ -0,0 +1,3 @@
1
+ import { ConformanceContext, ConformanceDoc, ConformanceFeatures, ConformanceHarness } from "./types.mjs";
2
+ import { runStandardRepoConformance } from "./conformance.mjs";
3
+ export { type ConformanceContext, type ConformanceDoc, type ConformanceFeatures, type ConformanceHarness, runStandardRepoConformance };
@@ -0,0 +1,2 @@
1
+ import { runStandardRepoConformance } from "./conformance.mjs";
2
+ export { runStandardRepoConformance };