@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.
- package/CHANGELOG.md +1263 -1098
- package/dist/cache/engine.d.mts +7 -5
- package/dist/cache/engine.mjs +34 -16
- package/dist/cache/memo.d.mts +4 -0
- package/dist/cache/memo.mjs +17 -4
- package/dist/cache/memory-adapter.mjs +6 -4
- package/dist/cache/plugin/read-hooks.mjs +1 -1
- package/dist/cache/stable-stringify.d.mts +7 -8
- package/dist/cache/stable-stringify.mjs +14 -11
- package/dist/cache/tag-index.mjs +1 -2
- package/dist/cache/types.d.mts +4 -3
- package/dist/errors/codes.d.mts +117 -0
- package/dist/errors/codes.mjs +114 -0
- package/dist/errors/contract.d.mts +2 -1
- package/dist/errors/contract.mjs +9 -3
- package/dist/errors/index.d.mts +3 -2
- package/dist/errors/index.mjs +2 -2
- package/dist/errors/types.d.mts +3 -30
- package/dist/filter/builders.mjs +1 -1
- package/dist/filter/match-record.mjs +9 -3
- package/dist/filter/match.mjs +25 -9
- package/dist/idempotency/index.d.mts +462 -0
- package/dist/idempotency/index.mjs +419 -0
- package/dist/pagination/types.d.mts +6 -0
- package/dist/query-parser/coerce.d.mts +18 -11
- package/dist/query-parser/coerce.mjs +37 -69
- package/dist/query-parser/errors.d.mts +30 -0
- package/dist/query-parser/errors.mjs +32 -0
- package/dist/query-parser/grammar.d.mts +107 -0
- package/dist/query-parser/grammar.mjs +462 -0
- package/dist/query-parser/index.d.mts +5 -2
- package/dist/query-parser/index.mjs +5 -2
- package/dist/query-parser/parse-url.d.mts +1 -1
- package/dist/query-parser/parse-url.mjs +72 -205
- package/dist/query-parser/regex-safety.d.mts +31 -0
- package/dist/query-parser/regex-safety.mjs +151 -0
- package/dist/query-parser/reserved.mjs +1 -0
- package/dist/query-parser/types.d.mts +15 -25
- package/dist/repository/types.d.mts +6 -5
- package/dist/schema/field-rules.mjs +49 -6
- package/dist/schema/standard-schema.mjs +1 -1
- package/dist/testing/idempotency-conformance.d.mts +15 -0
- package/dist/testing/idempotency-conformance.mjs +315 -0
- package/dist/testing/index.d.mts +3 -1
- package/dist/testing/index.mjs +3 -1
- package/dist/testing/query-grammar-conformance.d.mts +60 -0
- package/dist/testing/query-grammar-conformance.mjs +380 -0
- package/package.json +6 -2
- 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.
|
|
4
|
-
"description": "Driver-agnostic repository primitives: hooks, Filter IR, operations, pagination, cache contract. Foundation for mongokit, sqlitekit, pgkit, and prismakit. Lean by design
|
|
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"
|
package/dist/errors/types.mjs
DELETED
|
@@ -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 };
|