@classytic/repo-core 0.28.0 → 0.29.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 (49) hide show
  1. package/CHANGELOG.md +1263 -1098
  2. package/dist/cache/engine.d.mts +7 -5
  3. package/dist/cache/engine.mjs +34 -16
  4. package/dist/cache/memo.d.mts +4 -0
  5. package/dist/cache/memo.mjs +17 -4
  6. package/dist/cache/memory-adapter.mjs +6 -4
  7. package/dist/cache/plugin/read-hooks.mjs +1 -1
  8. package/dist/cache/stable-stringify.d.mts +7 -8
  9. package/dist/cache/stable-stringify.mjs +14 -11
  10. package/dist/cache/tag-index.mjs +1 -2
  11. package/dist/cache/types.d.mts +4 -3
  12. package/dist/errors/codes.d.mts +117 -0
  13. package/dist/errors/codes.mjs +114 -0
  14. package/dist/errors/contract.d.mts +2 -1
  15. package/dist/errors/contract.mjs +9 -3
  16. package/dist/errors/index.d.mts +3 -2
  17. package/dist/errors/index.mjs +2 -2
  18. package/dist/errors/types.d.mts +3 -30
  19. package/dist/filter/builders.mjs +1 -1
  20. package/dist/filter/match-record.mjs +9 -3
  21. package/dist/filter/match.mjs +25 -9
  22. package/dist/idempotency/index.d.mts +462 -0
  23. package/dist/idempotency/index.mjs +419 -0
  24. package/dist/pagination/types.d.mts +6 -0
  25. package/dist/query-parser/coerce.d.mts +18 -11
  26. package/dist/query-parser/coerce.mjs +37 -69
  27. package/dist/query-parser/errors.d.mts +30 -0
  28. package/dist/query-parser/errors.mjs +32 -0
  29. package/dist/query-parser/grammar.d.mts +107 -0
  30. package/dist/query-parser/grammar.mjs +462 -0
  31. package/dist/query-parser/index.d.mts +5 -2
  32. package/dist/query-parser/index.mjs +5 -2
  33. package/dist/query-parser/parse-url.d.mts +1 -1
  34. package/dist/query-parser/parse-url.mjs +72 -205
  35. package/dist/query-parser/regex-safety.d.mts +31 -0
  36. package/dist/query-parser/regex-safety.mjs +151 -0
  37. package/dist/query-parser/reserved.mjs +1 -0
  38. package/dist/query-parser/types.d.mts +15 -25
  39. package/dist/repository/types.d.mts +6 -5
  40. package/dist/schema/field-rules.mjs +49 -6
  41. package/dist/schema/standard-schema.mjs +1 -1
  42. package/dist/testing/idempotency-conformance.d.mts +15 -0
  43. package/dist/testing/idempotency-conformance.mjs +315 -0
  44. package/dist/testing/index.d.mts +3 -1
  45. package/dist/testing/index.mjs +3 -1
  46. package/dist/testing/query-grammar-conformance.d.mts +60 -0
  47. package/dist/testing/query-grammar-conformance.mjs +380 -0
  48. package/package.json +6 -2
  49. package/dist/errors/types.mjs +0 -29
@@ -0,0 +1,380 @@
1
+ import { describe, expect, it } from "vitest";
2
+ //#region src/testing/query-grammar-conformance.ts
3
+ /**
4
+ * `runQueryGrammarConformance` — every list-query parser reads a URL the same way.
5
+ *
6
+ * Cases are SEMANTIC: each parser's output is evaluated against one fixture set and must select
7
+ * the same rows (or refuse the same input with a 400). A parser passes by agreeing on RESULTS,
8
+ * so its emitted shape (Filter IR, Mongo record, a custom dialect) is free.
9
+ */
10
+ const at = (iso) => new Date(iso);
11
+ /** The fixture every case runs against: nulls, missing fields, codes, dates, and text metacharacters. */
12
+ const QUERY_GRAMMAR_DOCS = [
13
+ {
14
+ _id: "a",
15
+ status: "active",
16
+ qty: 5,
17
+ code: "007",
18
+ name: "Blue Hoodie",
19
+ price: 10,
20
+ tags: ["x"],
21
+ createdAt: at("2026-09-01T00:00:00Z"),
22
+ note: "50% off",
23
+ deletedAt: null
24
+ },
25
+ {
26
+ _id: "b",
27
+ status: "draft",
28
+ qty: 12,
29
+ code: "7",
30
+ name: "blue cap",
31
+ price: 100,
32
+ createdAt: at("2026-09-15T00:00:00Z"),
33
+ note: "a.b"
34
+ },
35
+ {
36
+ _id: "c",
37
+ status: "archived",
38
+ qty: 0,
39
+ code: "0123",
40
+ name: "Red Hoodie",
41
+ price: 55.5,
42
+ tags: [],
43
+ createdAt: at("2026-10-02T00:00:00Z"),
44
+ deletedAt: at("2026-10-03T00:00:00Z")
45
+ },
46
+ {
47
+ _id: "d",
48
+ name: "Green",
49
+ price: 7
50
+ },
51
+ {
52
+ _id: "e",
53
+ status: null,
54
+ name: "HOODIE",
55
+ price: 0,
56
+ note: "axb"
57
+ }
58
+ ];
59
+ const DEFAULT_OPTIONS = {
60
+ maxLimit: 100,
61
+ defaultLimit: 20
62
+ };
63
+ const QUERY_GRAMMAR_FILTER_CASES = [
64
+ {
65
+ query: "status=active",
66
+ ids: ["a"]
67
+ },
68
+ {
69
+ query: "status=active&status=draft",
70
+ ids: ["a", "b"]
71
+ },
72
+ {
73
+ query: "status[in]=active,draft",
74
+ ids: ["a", "b"]
75
+ },
76
+ {
77
+ query: "status[in]=active&status[in]=draft",
78
+ ids: ["a", "b"]
79
+ },
80
+ {
81
+ query: "status=active&status[in]=draft,archived",
82
+ ids: [
83
+ "a",
84
+ "b",
85
+ "c"
86
+ ]
87
+ },
88
+ {
89
+ query: "status[nin]=active&status[ne]=draft",
90
+ ids: ["c"]
91
+ },
92
+ {
93
+ query: "status[nin]=active,draft",
94
+ ids: ["c"]
95
+ },
96
+ {
97
+ query: "status[in]=null,active",
98
+ ids: [
99
+ "a",
100
+ "d",
101
+ "e"
102
+ ]
103
+ },
104
+ {
105
+ query: "status[nin]=null,active",
106
+ ids: ["b", "c"]
107
+ },
108
+ {
109
+ query: "status[ne]=active",
110
+ ids: ["b", "c"]
111
+ },
112
+ {
113
+ query: "status[exists]=true",
114
+ ids: [
115
+ "a",
116
+ "b",
117
+ "c"
118
+ ]
119
+ },
120
+ {
121
+ query: "status[exists]=false",
122
+ ids: ["d", "e"]
123
+ },
124
+ {
125
+ query: "filter[status]=active",
126
+ ids: ["a"]
127
+ },
128
+ {
129
+ query: "status=",
130
+ ids: [
131
+ "a",
132
+ "b",
133
+ "c",
134
+ "d",
135
+ "e"
136
+ ]
137
+ },
138
+ {
139
+ query: "qty=5",
140
+ ids: ["a"]
141
+ },
142
+ {
143
+ query: "qty[in]=5,12",
144
+ ids: ["a", "b"]
145
+ },
146
+ {
147
+ query: "qty[gte]=5",
148
+ ids: ["a", "b"]
149
+ },
150
+ {
151
+ query: "qty[lt]=5",
152
+ ids: ["c"]
153
+ },
154
+ {
155
+ query: "code=007",
156
+ ids: ["a"]
157
+ },
158
+ {
159
+ query: "code=0123",
160
+ ids: ["c"]
161
+ },
162
+ {
163
+ query: "price[gte]=10&price[lte]=100",
164
+ ids: [
165
+ "a",
166
+ "b",
167
+ "c"
168
+ ]
169
+ },
170
+ {
171
+ query: "price[between]=10,60",
172
+ ids: ["a", "c"]
173
+ },
174
+ {
175
+ query: "price[between]=,9",
176
+ ids: ["d", "e"]
177
+ },
178
+ {
179
+ query: "price=10&price[gte]=1",
180
+ ids: ["a"]
181
+ },
182
+ {
183
+ query: "createdAt[gte]=2026-09-10",
184
+ ids: ["b", "c"]
185
+ },
186
+ {
187
+ query: "createdAt[between]=2026-09-01,2026-09-30",
188
+ ids: ["a", "b"]
189
+ },
190
+ {
191
+ query: "name[contains]=hoodie",
192
+ ids: [
193
+ "a",
194
+ "c",
195
+ "e"
196
+ ]
197
+ },
198
+ {
199
+ query: "name[like]=hoodie",
200
+ ids: [
201
+ "a",
202
+ "c",
203
+ "e"
204
+ ]
205
+ },
206
+ {
207
+ query: "name[startsWith]=blue",
208
+ ids: ["a", "b"]
209
+ },
210
+ {
211
+ query: "name[endsWith]=hoodie",
212
+ ids: [
213
+ "a",
214
+ "c",
215
+ "e"
216
+ ]
217
+ },
218
+ {
219
+ query: "name[ieq]=hoodie",
220
+ ids: ["e"]
221
+ },
222
+ {
223
+ query: "note[contains]=a.b",
224
+ ids: ["b"]
225
+ },
226
+ {
227
+ query: "note[contains]=50%25",
228
+ ids: ["a"]
229
+ },
230
+ {
231
+ query: "name[regex]=^Blue",
232
+ ids: ["a"]
233
+ },
234
+ {
235
+ query: "name[regex]=^blue&name[options]=i",
236
+ ids: ["a", "b"]
237
+ },
238
+ {
239
+ query: "deletedAt=null",
240
+ ids: [
241
+ "a",
242
+ "b",
243
+ "d",
244
+ "e"
245
+ ]
246
+ },
247
+ {
248
+ query: "tags[exists]=false",
249
+ ids: [
250
+ "b",
251
+ "d",
252
+ "e"
253
+ ]
254
+ },
255
+ {
256
+ query: "status=active",
257
+ ids: ["a"],
258
+ options: { allowedFilterFields: ["status"] }
259
+ }
260
+ ];
261
+ const QUERY_GRAMMAR_REFUSED = [
262
+ { query: "x[foo]=1" },
263
+ { query: "meta[color]=red" },
264
+ { query: "meta[color][shade]=red" },
265
+ { query: "status[$ne]=x" },
266
+ { query: "$where=1" },
267
+ { query: "name[regex]=(a%2B)%2B" },
268
+ { query: "status[exists]=maybe" },
269
+ { query: "price[between]=1" },
270
+ { query: "price[between]=1,2,3" },
271
+ { query: "qty[gte]=null" },
272
+ { query: "name[options]=i" },
273
+ { query: "name[regex]=a&name[options]=g" },
274
+ {
275
+ query: "price=1",
276
+ options: { allowedFilterFields: ["status"] }
277
+ },
278
+ { query: "limit=0" },
279
+ { query: "limit=-1" },
280
+ { query: "limit=abc" },
281
+ { query: "limit=1.5" },
282
+ { query: "page=0" },
283
+ { query: "page=abc" },
284
+ { query: "sort=name;DROP" },
285
+ { query: "sort=$x" },
286
+ {
287
+ query: "sort=price",
288
+ options: { allowedSortFields: ["name"] }
289
+ }
290
+ ];
291
+ const QUERY_GRAMMAR_PAGING_CASES = [
292
+ {
293
+ query: "",
294
+ expect: { limit: 20 }
295
+ },
296
+ {
297
+ query: "limit=5",
298
+ expect: { limit: 5 }
299
+ },
300
+ {
301
+ query: "limit=500",
302
+ expect: { limit: 100 }
303
+ },
304
+ {
305
+ query: "page=2",
306
+ expect: {
307
+ limit: 20,
308
+ page: 2
309
+ }
310
+ },
311
+ {
312
+ query: "after=abc",
313
+ expect: {
314
+ limit: 20,
315
+ after: "abc"
316
+ }
317
+ },
318
+ {
319
+ query: "sort=-createdAt,name",
320
+ expect: {
321
+ limit: 20,
322
+ sort: {
323
+ createdAt: -1,
324
+ name: 1
325
+ }
326
+ }
327
+ }
328
+ ];
329
+ function statusOf(error) {
330
+ const e = error;
331
+ const status = e?.status ?? e?.statusCode;
332
+ return typeof status === "number" ? status : void 0;
333
+ }
334
+ const refusedWith400 = (attempt, query) => {
335
+ let caught;
336
+ try {
337
+ attempt();
338
+ } catch (error) {
339
+ caught = error;
340
+ }
341
+ expect(caught, `?${query} was accepted`).toBeDefined();
342
+ expect(statusOf(caught)).toBe(400);
343
+ };
344
+ function runQueryGrammarConformance(name, harness, conformance = {}) {
345
+ const parse = (query, options) => harness.parse(query, {
346
+ ...DEFAULT_OPTIONS,
347
+ ...options
348
+ });
349
+ const unsupported = conformance.unsupportedOperators ?? [];
350
+ const usesUnsupported = (query) => unsupported.some((op) => query.includes(`[${op}]`));
351
+ describe(`query grammar conformance — ${name}`, () => {
352
+ describe("filters select the same rows", () => {
353
+ for (const { query, ids, options } of QUERY_GRAMMAR_FILTER_CASES) it(`?${query}${options ? ` ${JSON.stringify(options)}` : ""}`, () => {
354
+ if (usesUnsupported(query)) {
355
+ refusedWith400(() => parse(query, options), query);
356
+ return;
357
+ }
358
+ const parsed = parse(query, options);
359
+ const matched = QUERY_GRAMMAR_DOCS.filter((doc) => parsed.matches(doc)).map((doc) => doc["_id"]);
360
+ expect(matched).toEqual([...ids]);
361
+ });
362
+ });
363
+ describe("invalid input is refused with a 400, never dropped", () => {
364
+ for (const { query, options } of QUERY_GRAMMAR_REFUSED) it(`?${query}${options ? ` ${JSON.stringify(options)}` : ""}`, () => {
365
+ refusedWith400(() => parse(query, options), query);
366
+ });
367
+ });
368
+ describe("paging and sort", () => {
369
+ for (const { query, expect: want } of QUERY_GRAMMAR_PAGING_CASES) it(`?${query}`, () => {
370
+ const parsed = parse(query);
371
+ expect(parsed.limit).toBe(want.limit);
372
+ if (want.page !== void 0) expect(parsed.page).toBe(want.page);
373
+ if (want.after !== void 0) expect(parsed.after).toBe(want.after);
374
+ if (want.sort !== void 0) expect(parsed.sort).toEqual(want.sort);
375
+ });
376
+ });
377
+ });
378
+ }
379
+ //#endregion
380
+ export { QUERY_GRAMMAR_DOCS, QUERY_GRAMMAR_FILTER_CASES, QUERY_GRAMMAR_PAGING_CASES, QUERY_GRAMMAR_REFUSED, runQueryGrammarConformance };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@classytic/repo-core",
3
- "version": "0.28.0",
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.",
3
+ "version": "0.29.0",
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,
7
7
  "files": [
@@ -98,6 +98,10 @@
98
98
  "types": "./dist/lock/index.d.mts",
99
99
  "default": "./dist/lock/index.mjs"
100
100
  },
101
+ "./idempotency": {
102
+ "types": "./dist/idempotency/index.d.mts",
103
+ "default": "./dist/idempotency/index.mjs"
104
+ },
101
105
  "./loader": {
102
106
  "types": "./dist/loader/index.d.mts",
103
107
  "default": "./dist/loader/index.mjs"
@@ -1,29 +0,0 @@
1
- //#region src/errors/types.ts
2
- /**
3
- * Cross-cutting error codes used across the org. Every canonical code is
4
- * lowercase + snake_case to match RFC 7807, Stripe, and Shopify
5
- * conventions. Domain packages add their own hierarchical codes
6
- * (`'order.validation.*'`, `'payment.gateway.*'`); these cover the
7
- * universal cases every HTTP-emitting layer needs.
8
- *
9
- * **Arc compatibility note.** Arc's `ArcError` hierarchy historically
10
- * uses UPPER_SNAKE codes (`'NOT_FOUND'`, `'VALIDATION_ERROR'`) for
11
- * back-compat with hosts that switch on those values. New code should
12
- * prefer the canonical lowercase codes; arc keeps emitting its UPPER_SNAKE
13
- * codes on the wire so existing client switches keep working.
14
- */
15
- const ERROR_CODES = {
16
- VALIDATION: "validation_error",
17
- NOT_FOUND: "not_found",
18
- CONFLICT: "conflict",
19
- UNAUTHORIZED: "unauthorized",
20
- FORBIDDEN: "forbidden",
21
- RATE_LIMITED: "rate_limited",
22
- IDEMPOTENCY_CONFLICT: "idempotency_conflict",
23
- PRECONDITION_FAILED: "precondition_failed",
24
- INTERNAL: "internal_error",
25
- UNAVAILABLE: "service_unavailable",
26
- TIMEOUT: "timeout"
27
- };
28
- //#endregion
29
- export { ERROR_CODES };