@jskit-ai/crud-core 0.1.52 → 0.1.54

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/crud-core",
4
- version: "0.1.52",
4
+ version: "0.1.54",
5
5
  kind: "runtime",
6
6
  description: "Shared CRUD helpers used by CRUD modules.",
7
7
  dependsOn: [
@@ -26,7 +26,7 @@ export default Object.freeze({
26
26
  mutations: {
27
27
  dependencies: {
28
28
  runtime: {
29
- "@jskit-ai/crud-core": "0.1.52"
29
+ "@jskit-ai/crud-core": "0.1.54"
30
30
  },
31
31
  dev: {}
32
32
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-core",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -22,16 +22,17 @@
22
22
  "./server/fieldAccess": "./src/server/fieldAccess.js",
23
23
  "./server/createCrudServiceFromResource": "./src/server/createCrudServiceFromResource.js",
24
24
  "./server/crudModuleConfig": "./src/server/crudModuleConfig.js",
25
- "./server/listQueryValidators": "./src/server/listQueryValidators.js"
25
+ "./server/listQueryValidators": "./src/server/listQueryValidators.js",
26
+ "./server/listFilters": "./src/server/listFilters.js"
26
27
  },
27
28
  "dependencies": {
28
29
  "@tanstack/vue-query": "^5.90.5",
29
- "@jskit-ai/database-runtime": "0.1.44",
30
- "@jskit-ai/kernel": "0.1.44",
31
- "@jskit-ai/realtime": "0.1.43",
32
- "@jskit-ai/shell-web": "0.1.43",
33
- "@jskit-ai/users-core": "0.1.54",
34
- "@jskit-ai/users-web": "0.1.59",
30
+ "@jskit-ai/database-runtime": "0.1.46",
31
+ "@jskit-ai/kernel": "0.1.46",
32
+ "@jskit-ai/realtime": "0.1.45",
33
+ "@jskit-ai/shell-web": "0.1.45",
34
+ "@jskit-ai/users-core": "0.1.56",
35
+ "@jskit-ai/users-web": "0.1.61",
35
36
  "typebox": "^1.0.81"
36
37
  }
37
38
  }
@@ -0,0 +1,547 @@
1
+ import { Type } from "typebox";
2
+ import {
3
+ normalizeObjectInput,
4
+ mergeObjectSchemas,
5
+ RECORD_ID_PATTERN
6
+ } from "@jskit-ai/kernel/shared/validators";
7
+ import {
8
+ normalizeBoolean,
9
+ normalizeCanonicalRecordIdText,
10
+ normalizeObject,
11
+ normalizeText,
12
+ normalizeUniqueTextList
13
+ } from "@jskit-ai/kernel/shared/support/normalize";
14
+ import {
15
+ defineCrudListFilters,
16
+ CRUD_LIST_FILTER_TYPE_FLAG,
17
+ CRUD_LIST_FILTER_TYPE_ENUM,
18
+ CRUD_LIST_FILTER_TYPE_ENUM_MANY,
19
+ CRUD_LIST_FILTER_TYPE_RECORD_ID,
20
+ CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY,
21
+ CRUD_LIST_FILTER_TYPE_DATE,
22
+ CRUD_LIST_FILTER_TYPE_DATE_RANGE,
23
+ CRUD_LIST_FILTER_TYPE_NUMBER_RANGE,
24
+ CRUD_LIST_FILTER_TYPE_PRESENCE
25
+ } from "@jskit-ai/kernel/shared/support/crudListFilters";
26
+
27
+ const DATE_FILTER_PATTERN_SOURCE = "^\\d{4}-\\d{2}-\\d{2}$";
28
+ const DATE_FILTER_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
29
+ const NUMBER_FILTER_PATTERN_SOURCE = "^[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?$";
30
+ const CRUD_LIST_FILTER_INVALID_VALUES_REJECT = "reject";
31
+ const CRUD_LIST_FILTER_INVALID_VALUES_DISCARD = "discard";
32
+ const CRUD_LIST_FILTER_INVALID_VALUES_MODES = Object.freeze([
33
+ CRUD_LIST_FILTER_INVALID_VALUES_REJECT,
34
+ CRUD_LIST_FILTER_INVALID_VALUES_DISCARD
35
+ ]);
36
+ const looseTextInputSchema = Type.String({ minLength: 0 });
37
+ const strictNumberInputSchema = Type.Union([
38
+ Type.String({ pattern: NUMBER_FILTER_PATTERN_SOURCE }),
39
+ Type.Number()
40
+ ]);
41
+ const looseStringOrNumberSchema = Type.Union([
42
+ looseTextInputSchema,
43
+ Type.Number()
44
+ ]);
45
+ const recordIdInputSchema = Type.Union([
46
+ Type.String({ pattern: RECORD_ID_PATTERN }),
47
+ Type.Number({ minimum: 1 })
48
+ ]);
49
+ const flagInputSchema = Type.Union([
50
+ Type.String({ minLength: 0 }),
51
+ Type.Boolean(),
52
+ Type.Number()
53
+ ]);
54
+
55
+ function normalizeCrudListFilterInvalidValues(value = "") {
56
+ const normalized = normalizeText(value).toLowerCase();
57
+ if (CRUD_LIST_FILTER_INVALID_VALUES_MODES.includes(normalized)) {
58
+ return normalized;
59
+ }
60
+
61
+ throw new TypeError(
62
+ `Unsupported CRUD list filter invalidValues mode "${value}". Expected one of: ${CRUD_LIST_FILTER_INVALID_VALUES_MODES.join(", ")}.`
63
+ );
64
+ }
65
+
66
+ function createSingleOrMultiValueSchema(itemSchema) {
67
+ return Type.Optional(
68
+ Type.Union([
69
+ itemSchema,
70
+ Type.Array(itemSchema, {
71
+ minItems: 1
72
+ })
73
+ ])
74
+ );
75
+ }
76
+
77
+ function firstValue(value) {
78
+ if (Array.isArray(value)) {
79
+ return value[0];
80
+ }
81
+
82
+ return value;
83
+ }
84
+
85
+ function normalizeDateFilterValue(value) {
86
+ const normalized = normalizeText(firstValue(value));
87
+ if (!normalized || !DATE_FILTER_PATTERN.test(normalized)) {
88
+ return "";
89
+ }
90
+
91
+ return normalized;
92
+ }
93
+
94
+ function normalizeNumberFilterValue(value) {
95
+ if (value == null || value === "") {
96
+ return null;
97
+ }
98
+
99
+ const normalized = typeof value === "number"
100
+ ? value
101
+ : Number(normalizeText(firstValue(value)));
102
+ return Number.isFinite(normalized) ? normalized : null;
103
+ }
104
+
105
+ function normalizeRecordIdFilterValue(value) {
106
+ return normalizeCanonicalRecordIdText(firstValue(value), {
107
+ fallback: ""
108
+ }) || "";
109
+ }
110
+
111
+ function normalizeRecordIdFilterValues(value) {
112
+ return normalizeUniqueTextList(value, {
113
+ acceptSingle: true
114
+ })
115
+ .map((entry) => normalizeCanonicalRecordIdText(entry, { fallback: "" }))
116
+ .filter(Boolean);
117
+ }
118
+
119
+ function resolveAllowedOptionValues(filter = {}) {
120
+ return new Set(
121
+ (Array.isArray(filter.options) ? filter.options : [])
122
+ .map((entry) => normalizeText(entry?.value))
123
+ .filter(Boolean)
124
+ );
125
+ }
126
+
127
+ function normalizeAllowedTextValue(value, allowedValues = new Set()) {
128
+ const normalized = normalizeText(firstValue(value));
129
+ if (!normalized || !allowedValues.has(normalized)) {
130
+ return "";
131
+ }
132
+
133
+ return normalized;
134
+ }
135
+
136
+ function normalizeAllowedTextValues(value, allowedValues = new Set()) {
137
+ return normalizeUniqueTextList(value, {
138
+ acceptSingle: true
139
+ }).filter((entry) => allowedValues.has(entry));
140
+ }
141
+
142
+ function addDaysToDateFilterValue(value = "", days = 0) {
143
+ const normalizedValue = normalizeDateFilterValue(value);
144
+ if (!normalizedValue || !Number.isInteger(days)) {
145
+ return "";
146
+ }
147
+
148
+ const date = new Date(`${normalizedValue}T00:00:00.000Z`);
149
+ if (Number.isNaN(date.getTime())) {
150
+ return "";
151
+ }
152
+
153
+ date.setUTCDate(date.getUTCDate() + days);
154
+ const year = date.getUTCFullYear();
155
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
156
+ const day = String(date.getUTCDate()).padStart(2, "0");
157
+ return `${year}-${month}-${day}`;
158
+ }
159
+
160
+ function createFilterQuerySchema(filter = {}, { invalidValues = CRUD_LIST_FILTER_INVALID_VALUES_REJECT } = {}) {
161
+ const invalidValueMode = normalizeCrudListFilterInvalidValues(invalidValues);
162
+ const discardInvalidValues = invalidValueMode === CRUD_LIST_FILTER_INVALID_VALUES_DISCARD;
163
+ const allowedValues = (Array.isArray(filter.options) ? filter.options : []).map((entry) => entry.value);
164
+
165
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
166
+ return Type.Object(
167
+ {
168
+ [filter.queryKey]: Type.Optional(flagInputSchema)
169
+ },
170
+ { additionalProperties: false }
171
+ );
172
+ }
173
+
174
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM || filter.type === CRUD_LIST_FILTER_TYPE_PRESENCE) {
175
+ return Type.Object(
176
+ {
177
+ [filter.queryKey]: Type.Optional(
178
+ discardInvalidValues
179
+ ? looseTextInputSchema
180
+ : Type.String({ enum: allowedValues })
181
+ )
182
+ },
183
+ { additionalProperties: false }
184
+ );
185
+ }
186
+
187
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY) {
188
+ return Type.Object(
189
+ {
190
+ [filter.queryKey]: createSingleOrMultiValueSchema(
191
+ discardInvalidValues
192
+ ? looseTextInputSchema
193
+ : Type.String({ enum: allowedValues })
194
+ )
195
+ },
196
+ { additionalProperties: false }
197
+ );
198
+ }
199
+
200
+ if (filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID) {
201
+ return Type.Object(
202
+ {
203
+ [filter.queryKey]: Type.Optional(
204
+ discardInvalidValues
205
+ ? looseStringOrNumberSchema
206
+ : recordIdInputSchema
207
+ )
208
+ },
209
+ { additionalProperties: false }
210
+ );
211
+ }
212
+
213
+ if (filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
214
+ return Type.Object(
215
+ {
216
+ [filter.queryKey]: createSingleOrMultiValueSchema(
217
+ discardInvalidValues
218
+ ? looseStringOrNumberSchema
219
+ : recordIdInputSchema
220
+ )
221
+ },
222
+ { additionalProperties: false }
223
+ );
224
+ }
225
+
226
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE) {
227
+ return Type.Object(
228
+ {
229
+ [filter.queryKey]: Type.Optional(
230
+ discardInvalidValues
231
+ ? looseTextInputSchema
232
+ : Type.String({ pattern: DATE_FILTER_PATTERN_SOURCE })
233
+ )
234
+ },
235
+ { additionalProperties: false }
236
+ );
237
+ }
238
+
239
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
240
+ return Type.Object(
241
+ {
242
+ [filter.fromKey]: Type.Optional(
243
+ discardInvalidValues
244
+ ? looseTextInputSchema
245
+ : Type.String({ pattern: DATE_FILTER_PATTERN_SOURCE })
246
+ ),
247
+ [filter.toKey]: Type.Optional(
248
+ discardInvalidValues
249
+ ? looseTextInputSchema
250
+ : Type.String({ pattern: DATE_FILTER_PATTERN_SOURCE })
251
+ )
252
+ },
253
+ { additionalProperties: false }
254
+ );
255
+ }
256
+
257
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
258
+ return Type.Object(
259
+ {
260
+ [filter.minKey]: Type.Optional(
261
+ discardInvalidValues
262
+ ? looseStringOrNumberSchema
263
+ : strictNumberInputSchema
264
+ ),
265
+ [filter.maxKey]: Type.Optional(
266
+ discardInvalidValues
267
+ ? looseStringOrNumberSchema
268
+ : strictNumberInputSchema
269
+ )
270
+ },
271
+ { additionalProperties: false }
272
+ );
273
+ }
274
+
275
+ return Type.Object({}, { additionalProperties: false });
276
+ }
277
+
278
+ function normalizeFilterValue(filter = {}, source = {}) {
279
+ const allowedValues = resolveAllowedOptionValues(filter);
280
+
281
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
282
+ if (!Object.hasOwn(source, filter.queryKey)) {
283
+ return undefined;
284
+ }
285
+
286
+ const sourceValue = source[filter.queryKey];
287
+ if (sourceValue === null || sourceValue === "") {
288
+ return true;
289
+ }
290
+
291
+ try {
292
+ return normalizeBoolean(firstValue(sourceValue));
293
+ } catch {
294
+ return undefined;
295
+ }
296
+ }
297
+
298
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM || filter.type === CRUD_LIST_FILTER_TYPE_PRESENCE) {
299
+ if (!Object.hasOwn(source, filter.queryKey)) {
300
+ return undefined;
301
+ }
302
+
303
+ const normalizedValue = normalizeAllowedTextValue(source[filter.queryKey], allowedValues);
304
+ return normalizedValue || undefined;
305
+ }
306
+
307
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY) {
308
+ if (!Object.hasOwn(source, filter.queryKey)) {
309
+ return undefined;
310
+ }
311
+
312
+ const normalizedValues = normalizeAllowedTextValues(source[filter.queryKey], allowedValues);
313
+ return normalizedValues.length > 0 ? normalizedValues : undefined;
314
+ }
315
+
316
+ if (filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID) {
317
+ if (!Object.hasOwn(source, filter.queryKey)) {
318
+ return undefined;
319
+ }
320
+
321
+ const normalizedValue = normalizeRecordIdFilterValue(source[filter.queryKey]);
322
+ return normalizedValue || undefined;
323
+ }
324
+
325
+ if (filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
326
+ if (!Object.hasOwn(source, filter.queryKey)) {
327
+ return undefined;
328
+ }
329
+
330
+ const normalizedValues = normalizeRecordIdFilterValues(source[filter.queryKey]);
331
+ return normalizedValues.length > 0 ? normalizedValues : undefined;
332
+ }
333
+
334
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE) {
335
+ if (!Object.hasOwn(source, filter.queryKey)) {
336
+ return undefined;
337
+ }
338
+
339
+ const normalizedValue = normalizeDateFilterValue(source[filter.queryKey]);
340
+ return normalizedValue || undefined;
341
+ }
342
+
343
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
344
+ const from = normalizeDateFilterValue(source[filter.fromKey]);
345
+ const to = normalizeDateFilterValue(source[filter.toKey]);
346
+ if (!from && !to) {
347
+ return undefined;
348
+ }
349
+
350
+ return Object.freeze({
351
+ ...(from ? { from } : {}),
352
+ ...(to ? { to } : {})
353
+ });
354
+ }
355
+
356
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
357
+ const min = normalizeNumberFilterValue(source[filter.minKey]);
358
+ const max = normalizeNumberFilterValue(source[filter.maxKey]);
359
+ if (min == null && max == null) {
360
+ return undefined;
361
+ }
362
+
363
+ return Object.freeze({
364
+ ...(min != null ? { min } : {}),
365
+ ...(max != null ? { max } : {})
366
+ });
367
+ }
368
+
369
+ return undefined;
370
+ }
371
+
372
+ function normalizeColumnsMap(columns = {}) {
373
+ const source = normalizeObject(columns);
374
+ const normalized = {};
375
+ for (const [key, value] of Object.entries(source)) {
376
+ const normalizedKey = normalizeText(key);
377
+ const normalizedValue = normalizeText(value);
378
+ if (!normalizedKey || !normalizedValue) {
379
+ continue;
380
+ }
381
+
382
+ normalized[normalizedKey] = normalizedValue;
383
+ }
384
+
385
+ return Object.freeze(normalized);
386
+ }
387
+
388
+ function applyDefaultFilterQuery(queryBuilder, filter = {}, value, column = "") {
389
+ if (!column) {
390
+ return queryBuilder;
391
+ }
392
+
393
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
394
+ if (value === true) {
395
+ queryBuilder.where(column, true);
396
+ }
397
+ return queryBuilder;
398
+ }
399
+
400
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID) {
401
+ if (value !== undefined) {
402
+ queryBuilder.where(column, value);
403
+ }
404
+ return queryBuilder;
405
+ }
406
+
407
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
408
+ if (Array.isArray(value) && value.length > 0) {
409
+ queryBuilder.whereIn(column, value);
410
+ }
411
+ return queryBuilder;
412
+ }
413
+
414
+ if (filter.type === CRUD_LIST_FILTER_TYPE_PRESENCE) {
415
+ if (value === "present") {
416
+ queryBuilder.whereNotNull(column);
417
+ }
418
+ if (value === "missing") {
419
+ queryBuilder.whereNull(column);
420
+ }
421
+ return queryBuilder;
422
+ }
423
+
424
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE) {
425
+ if (value) {
426
+ const nextDate = addDaysToDateFilterValue(value, 1);
427
+ queryBuilder.where(column, ">=", `${value} 00:00:00`);
428
+ if (nextDate) {
429
+ queryBuilder.where(column, "<", `${nextDate} 00:00:00`);
430
+ }
431
+ }
432
+ return queryBuilder;
433
+ }
434
+
435
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
436
+ if (value?.from) {
437
+ queryBuilder.where(column, ">=", `${value.from} 00:00:00`);
438
+ }
439
+ if (value?.to) {
440
+ const nextDate = addDaysToDateFilterValue(value.to, 1);
441
+ if (nextDate) {
442
+ queryBuilder.where(column, "<", `${nextDate} 00:00:00`);
443
+ }
444
+ }
445
+ return queryBuilder;
446
+ }
447
+
448
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
449
+ if (value?.min != null) {
450
+ queryBuilder.where(column, ">=", value.min);
451
+ }
452
+ if (value?.max != null) {
453
+ queryBuilder.where(column, "<=", value.max);
454
+ }
455
+ return queryBuilder;
456
+ }
457
+
458
+ return queryBuilder;
459
+ }
460
+
461
+ function createCrudListFilters(definitions = {}, { columns = {}, apply = {} } = {}) {
462
+ const normalizedFilters = defineCrudListFilters(definitions);
463
+ const normalizedColumns = normalizeColumnsMap(columns);
464
+ const filterEntries = Object.values(normalizedFilters);
465
+
466
+ function normalize(payload = {}) {
467
+ const source = normalizeObjectInput(payload);
468
+ const normalized = {};
469
+
470
+ for (const filter of filterEntries) {
471
+ const value = normalizeFilterValue(filter, source);
472
+ if (value === undefined) {
473
+ continue;
474
+ }
475
+
476
+ normalized[filter.key] = value;
477
+ }
478
+
479
+ return Object.freeze(normalized);
480
+ }
481
+
482
+ const queryValidators = Object.freeze({
483
+ [CRUD_LIST_FILTER_INVALID_VALUES_REJECT]: Object.freeze({
484
+ schema: mergeObjectSchemas(
485
+ filterEntries.map((filter) => createFilterQuerySchema(filter, {
486
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_REJECT
487
+ }))
488
+ ),
489
+ normalize
490
+ }),
491
+ [CRUD_LIST_FILTER_INVALID_VALUES_DISCARD]: Object.freeze({
492
+ schema: mergeObjectSchemas(
493
+ filterEntries.map((filter) => createFilterQuerySchema(filter, {
494
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_DISCARD
495
+ }))
496
+ ),
497
+ normalize
498
+ })
499
+ });
500
+
501
+ function applyQuery(queryBuilder, payload = {}) {
502
+ if (!queryBuilder || typeof queryBuilder.where !== "function") {
503
+ throw new TypeError("createCrudListFilters.applyQuery requires query builder.");
504
+ }
505
+
506
+ const normalized = normalize(payload);
507
+ for (const filter of filterEntries) {
508
+ if (!Object.hasOwn(normalized, filter.key)) {
509
+ continue;
510
+ }
511
+
512
+ const value = normalized[filter.key];
513
+ const customApply = typeof apply?.[filter.key] === "function"
514
+ ? apply[filter.key]
515
+ : null;
516
+ if (customApply) {
517
+ customApply(queryBuilder, value, {
518
+ filter,
519
+ filters: normalized
520
+ });
521
+ continue;
522
+ }
523
+
524
+ applyDefaultFilterQuery(queryBuilder, filter, value, normalizedColumns[filter.key] || "");
525
+ }
526
+
527
+ return queryBuilder;
528
+ }
529
+
530
+ function createQueryValidator({ invalidValues } = {}) {
531
+ const invalidValueMode = normalizeCrudListFilterInvalidValues(invalidValues);
532
+ return queryValidators[invalidValueMode];
533
+ }
534
+
535
+ return Object.freeze({
536
+ filters: normalizedFilters,
537
+ createQueryValidator,
538
+ normalize,
539
+ applyQuery
540
+ });
541
+ }
542
+
543
+ export {
544
+ CRUD_LIST_FILTER_INVALID_VALUES_REJECT,
545
+ CRUD_LIST_FILTER_INVALID_VALUES_DISCARD,
546
+ createCrudListFilters
547
+ };
@@ -0,0 +1,379 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { Check } from "typebox/value";
4
+ import { compileRouteValidator } from "@jskit-ai/kernel/_testable";
5
+ import { cursorPaginationQueryValidator } from "@jskit-ai/kernel/shared/validators";
6
+ import { listSearchQueryValidator } from "../src/server/listQueryValidators.js";
7
+ import {
8
+ CRUD_LIST_FILTER_INVALID_VALUES_REJECT,
9
+ CRUD_LIST_FILTER_INVALID_VALUES_DISCARD,
10
+ createCrudListFilters
11
+ } from "../src/server/listFilters.js";
12
+
13
+ test("crud-core exposes createCrudListFilters through the public package export", async () => {
14
+ const module = await import("@jskit-ai/crud-core/server/listFilters");
15
+ assert.equal(typeof module.createCrudListFilters, "function");
16
+ assert.equal(module.CRUD_LIST_FILTER_INVALID_VALUES_REJECT, CRUD_LIST_FILTER_INVALID_VALUES_REJECT);
17
+ assert.equal(module.CRUD_LIST_FILTER_INVALID_VALUES_DISCARD, CRUD_LIST_FILTER_INVALID_VALUES_DISCARD);
18
+ });
19
+
20
+ function createQueryDouble() {
21
+ const calls = [];
22
+ const nestedQuery = {
23
+ where(...args) {
24
+ calls.push(["innerWhere", ...args]);
25
+ return nestedQuery;
26
+ },
27
+ orWhere(...args) {
28
+ calls.push(["innerOrWhere", ...args]);
29
+ return nestedQuery;
30
+ },
31
+ whereNull(...args) {
32
+ calls.push(["innerWhereNull", ...args]);
33
+ return nestedQuery;
34
+ },
35
+ whereNotNull(...args) {
36
+ calls.push(["innerWhereNotNull", ...args]);
37
+ return nestedQuery;
38
+ }
39
+ };
40
+
41
+ const query = {
42
+ where(...args) {
43
+ if (args.length === 1 && typeof args[0] === "function") {
44
+ calls.push(["whereGroup"]);
45
+ args[0](nestedQuery);
46
+ return query;
47
+ }
48
+
49
+ calls.push(["where", ...args]);
50
+ return query;
51
+ },
52
+ whereIn(...args) {
53
+ calls.push(["whereIn", ...args]);
54
+ return query;
55
+ },
56
+ whereNull(...args) {
57
+ calls.push(["whereNull", ...args]);
58
+ return query;
59
+ },
60
+ whereNotNull(...args) {
61
+ calls.push(["whereNotNull", ...args]);
62
+ return query;
63
+ }
64
+ };
65
+
66
+ return {
67
+ query,
68
+ calls
69
+ };
70
+ }
71
+
72
+ test("createCrudListFilters normalizes filters into semantic values", () => {
73
+ const runtime = createCrudListFilters({
74
+ onlyStaff: {
75
+ type: "flag",
76
+ label: "Staff"
77
+ },
78
+ status: {
79
+ type: "enumMany",
80
+ label: "Status",
81
+ options: [
82
+ { value: "active", label: "Active" },
83
+ { value: "archived", label: "Archived" }
84
+ ]
85
+ },
86
+ arrivalDate: {
87
+ type: "dateRange",
88
+ label: "Arrival Date"
89
+ },
90
+ supplierContactId: {
91
+ type: "recordIdMany",
92
+ label: "Supplier"
93
+ },
94
+ weight: {
95
+ type: "numberRange",
96
+ label: "Weight"
97
+ }
98
+ });
99
+
100
+ const normalized = runtime.normalize({
101
+ onlyStaff: "",
102
+ status: ["active", "ignored", "archived"],
103
+ arrivalDateFrom: "2026-04-01",
104
+ arrivalDateTo: "2026-04-30",
105
+ supplierContactId: ["7", "bad", "4"],
106
+ weightMin: "12.5",
107
+ weightMax: 18
108
+ });
109
+
110
+ assert.deepEqual(normalized, {
111
+ onlyStaff: true,
112
+ status: ["active", "archived"],
113
+ arrivalDate: {
114
+ from: "2026-04-01",
115
+ to: "2026-04-30"
116
+ },
117
+ supplierContactId: ["7", "4"],
118
+ weight: {
119
+ min: 12.5,
120
+ max: 18
121
+ }
122
+ });
123
+ });
124
+
125
+ test("createCrudListFilters applies default column filters by type", () => {
126
+ const runtime = createCrudListFilters(
127
+ {
128
+ onlyStaff: {
129
+ type: "flag",
130
+ label: "Staff"
131
+ },
132
+ status: {
133
+ type: "enumMany",
134
+ label: "Status",
135
+ options: [
136
+ { value: "active", label: "Active" },
137
+ { value: "archived", label: "Archived" }
138
+ ]
139
+ },
140
+ supplierContactId: {
141
+ type: "recordId",
142
+ label: "Supplier"
143
+ },
144
+ arrivalDate: {
145
+ type: "dateRange",
146
+ label: "Arrival Date"
147
+ },
148
+ weight: {
149
+ type: "numberRange",
150
+ label: "Weight"
151
+ },
152
+ locationAssignment: {
153
+ type: "presence",
154
+ label: "Storage"
155
+ }
156
+ },
157
+ {
158
+ columns: {
159
+ onlyStaff: "is_staff",
160
+ status: "status",
161
+ supplierContactId: "supplier_contact_id",
162
+ arrivalDate: "arrival_datetime",
163
+ weight: "weight_received",
164
+ locationAssignment: "location_id"
165
+ }
166
+ }
167
+ );
168
+ const { query, calls } = createQueryDouble();
169
+
170
+ runtime.applyQuery(query, {
171
+ onlyStaff: "",
172
+ status: ["active", "archived"],
173
+ supplierContactId: "7",
174
+ arrivalDateFrom: "2026-04-01",
175
+ arrivalDateTo: "2026-04-30",
176
+ weightMin: "12.5",
177
+ weightMax: "18",
178
+ locationAssignment: "missing"
179
+ });
180
+
181
+ assert.deepEqual(calls, [
182
+ ["where", "is_staff", true],
183
+ ["whereIn", "status", ["active", "archived"]],
184
+ ["where", "supplier_contact_id", "7"],
185
+ ["where", "arrival_datetime", ">=", "2026-04-01 00:00:00"],
186
+ ["where", "arrival_datetime", "<", "2026-05-01 00:00:00"],
187
+ ["where", "weight_received", ">=", 12.5],
188
+ ["where", "weight_received", "<=", 18],
189
+ ["whereNull", "location_id"]
190
+ ]);
191
+ });
192
+
193
+ test("createCrudListFilters supports custom apply overrides for complex filters", () => {
194
+ const runtime = createCrudListFilters(
195
+ {
196
+ ccp1Status: {
197
+ type: "enumMany",
198
+ label: "CCP1",
199
+ options: [
200
+ { value: "pending", label: "Pending" },
201
+ { value: "passed", label: "Passed" },
202
+ { value: "failed", label: "Failed" }
203
+ ]
204
+ }
205
+ },
206
+ {
207
+ apply: {
208
+ ccp1Status(queryBuilder, values) {
209
+ queryBuilder.where((statusQuery) => {
210
+ if (values.includes("pending")) {
211
+ statusQuery.whereNull("ccp1_passed");
212
+ }
213
+ if (values.includes("failed")) {
214
+ statusQuery.orWhere("ccp1_passed", false);
215
+ }
216
+ });
217
+ }
218
+ }
219
+ }
220
+ );
221
+ const { query, calls } = createQueryDouble();
222
+
223
+ runtime.applyQuery(query, {
224
+ ccp1Status: ["pending", "failed"]
225
+ });
226
+
227
+ assert.deepEqual(calls, [
228
+ ["whereGroup"],
229
+ ["innerWhereNull", "ccp1_passed"],
230
+ ["innerOrWhere", "ccp1_passed", false]
231
+ ]);
232
+ });
233
+
234
+ test("createCrudListFilters query validator stays mergeable with search and cursor validators", () => {
235
+ const runtime = createCrudListFilters({
236
+ status: {
237
+ type: "enum",
238
+ label: "Status",
239
+ options: [
240
+ { value: "active", label: "Active" },
241
+ { value: "archived", label: "Archived" }
242
+ ]
243
+ },
244
+ arrivalDate: {
245
+ type: "dateRange",
246
+ label: "Arrival Date"
247
+ }
248
+ });
249
+
250
+ const compiled = compileRouteValidator({
251
+ queryValidator: [
252
+ cursorPaginationQueryValidator,
253
+ listSearchQueryValidator,
254
+ runtime.createQueryValidator({
255
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_REJECT
256
+ })
257
+ ]
258
+ });
259
+
260
+ assert.deepEqual(compiled.schema.querystring.required || [], []);
261
+ });
262
+
263
+ test("createCrudListFilters requires explicit invalid-value mode for new query validators", () => {
264
+ const runtime = createCrudListFilters({});
265
+
266
+ assert.throws(
267
+ () => runtime.createQueryValidator(),
268
+ /invalidValues mode/
269
+ );
270
+ });
271
+
272
+ test("createCrudListFilters reject validator keeps strict filter schemas", () => {
273
+ const runtime = createCrudListFilters({
274
+ arrivalDate: {
275
+ type: "dateRange",
276
+ label: "Arrival Date"
277
+ },
278
+ status: {
279
+ type: "enumMany",
280
+ label: "Status",
281
+ options: [
282
+ { value: "active", label: "Active" },
283
+ { value: "archived", label: "Archived" }
284
+ ]
285
+ },
286
+ supplierContactId: {
287
+ type: "recordIdMany",
288
+ label: "Supplier"
289
+ },
290
+ weight: {
291
+ type: "numberRange",
292
+ label: "Weight"
293
+ }
294
+ });
295
+
296
+ const validator = runtime.createQueryValidator({
297
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_REJECT
298
+ });
299
+
300
+ assert.equal(Check(validator.schema, {
301
+ arrivalDateFrom: "2026-04-01",
302
+ status: ["active"],
303
+ supplierContactId: ["7"],
304
+ weightMin: "12.5"
305
+ }), true);
306
+ assert.equal(Check(validator.schema, {
307
+ arrivalDateFrom: "bad-date"
308
+ }), false);
309
+ assert.equal(Check(validator.schema, {
310
+ status: ["active", "unexpected"]
311
+ }), false);
312
+ assert.equal(Check(validator.schema, {
313
+ supplierContactId: ["7", "bad"]
314
+ }), false);
315
+ assert.equal(Check(validator.schema, {
316
+ weightMin: "bad"
317
+ }), false);
318
+ });
319
+
320
+ test("createCrudListFilters discard validator accepts malformed values and lets normalize drop them", () => {
321
+ const runtime = createCrudListFilters({
322
+ arrivalDate: {
323
+ type: "dateRange",
324
+ label: "Arrival Date"
325
+ },
326
+ status: {
327
+ type: "enumMany",
328
+ label: "Status",
329
+ options: [
330
+ { value: "active", label: "Active" },
331
+ { value: "archived", label: "Archived" }
332
+ ]
333
+ },
334
+ supplierContactId: {
335
+ type: "recordIdMany",
336
+ label: "Supplier"
337
+ },
338
+ weight: {
339
+ type: "numberRange",
340
+ label: "Weight"
341
+ }
342
+ });
343
+
344
+ const validator = runtime.createQueryValidator({
345
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_DISCARD
346
+ });
347
+
348
+ assert.equal(Check(validator.schema, {
349
+ arrivalDateFrom: "bad-date",
350
+ status: ["active", "unexpected"],
351
+ supplierContactId: ["7", "bad"],
352
+ weightMin: "bad"
353
+ }), true);
354
+ assert.deepEqual(validator.normalize({
355
+ arrivalDateFrom: "bad-date",
356
+ arrivalDateTo: "2026-04-30",
357
+ status: ["active", "unexpected"],
358
+ supplierContactId: ["7", "bad"],
359
+ weightMin: "bad"
360
+ }), {
361
+ arrivalDate: {
362
+ to: "2026-04-30"
363
+ },
364
+ status: ["active"],
365
+ supplierContactId: ["7"]
366
+ });
367
+ });
368
+
369
+ test("createCrudListFilters exposes no default query validator alias", () => {
370
+ const runtime = createCrudListFilters({
371
+ arrivalDate: {
372
+ type: "dateRange",
373
+ label: "Arrival Date"
374
+ }
375
+ });
376
+
377
+ assert.equal(Object.hasOwn(runtime, "queryValidator"), false);
378
+ assert.equal(runtime.queryValidator, undefined);
379
+ });