@jskit-ai/crud-core 0.1.100 → 0.1.101

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.100",
4
+ version: "0.1.101",
5
5
  kind: "runtime",
6
6
  description: "Shared CRUD helpers used by CRUD modules.",
7
7
  dependsOn: [
@@ -28,7 +28,7 @@ export default Object.freeze({
28
28
  mutations: {
29
29
  dependencies: {
30
30
  runtime: {
31
- "@jskit-ai/crud-core": "0.1.100"
31
+ "@jskit-ai/crud-core": "0.1.101"
32
32
  },
33
33
  dev: {}
34
34
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-core",
3
- "version": "0.1.100",
3
+ "version": "0.1.101",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -27,15 +27,15 @@
27
27
  "./server/routeContracts": "./src/server/routeContracts.js"
28
28
  },
29
29
  "dependencies": {
30
- "@jskit-ai/database-runtime": "0.1.92",
31
- "@jskit-ai/http-runtime": "0.1.91",
32
- "@jskit-ai/kernel": "0.1.92",
30
+ "@jskit-ai/database-runtime": "0.1.93",
31
+ "@jskit-ai/http-runtime": "0.1.92",
32
+ "@jskit-ai/kernel": "0.1.93",
33
33
  "json-rest-schema": "1.x.x",
34
- "@jskit-ai/realtime": "0.1.91",
35
- "@jskit-ai/resource-crud-core": "0.1.37",
36
- "@jskit-ai/shell-web": "0.1.91",
37
- "@jskit-ai/users-core": "0.1.102",
38
- "@jskit-ai/users-web": "0.1.107"
34
+ "@jskit-ai/realtime": "0.1.92",
35
+ "@jskit-ai/resource-crud-core": "0.1.38",
36
+ "@jskit-ai/shell-web": "0.1.92",
37
+ "@jskit-ai/users-core": "0.1.103",
38
+ "@jskit-ai/users-web": "0.1.108"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@tanstack/vue-query": "^5.90.5",
@@ -21,6 +21,8 @@ import {
21
21
  CRUD_LIST_FILTER_TYPE_DATE_RANGE,
22
22
  CRUD_LIST_FILTER_TYPE_NUMBER_RANGE,
23
23
  CRUD_LIST_FILTER_TYPE_PRESENCE,
24
+ CRUD_LIST_FILTER_PRESENCE_PRESENT,
25
+ CRUD_LIST_FILTER_PRESENCE_MISSING,
24
26
  INVALID_CRUD_LIST_FILTER_QUERY_VALUE,
25
27
  normalizeCrudListFilterInvalidValues,
26
28
  parseCrudListFilterQueryValue
@@ -34,6 +36,7 @@ const NUMBER_FILTER_VALUE_PATTERN_SOURCE = "[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:
34
36
  const NUMBER_RANGE_FILTER_PATTERN_SOURCE =
35
37
  `^(?:${NUMBER_FILTER_VALUE_PATTERN_SOURCE}(?:\\.\\.(?:${NUMBER_FILTER_VALUE_PATTERN_SOURCE})?)?|\\.\\.${NUMBER_FILTER_VALUE_PATTERN_SOURCE})$`;
36
38
  const CRUD_LIST_FILTER_QUERY_TYPE = "crudListFilterQuery";
39
+ const JSON_REST_INTERNAL_FILTER_PREFIX = "__jskit";
37
40
  const crudListFilterSchemaFactory = createSchema.createFactory();
38
41
  const looseTextTransportSchema = Object.freeze({
39
42
  type: "string",
@@ -462,6 +465,276 @@ function normalizeColumnsMap(columns = {}) {
462
465
  return Object.freeze(normalized);
463
466
  }
464
467
 
468
+ function resolveJsonRestFilterActualField(filter = {}, columns = {}) {
469
+ const meta = isPlainObject(filter.meta?.jsonRest) ? filter.meta.jsonRest : {};
470
+ return normalizeText(meta.actualField || meta.column || columns[filter.key] || filter.key);
471
+ }
472
+
473
+ function renderInternalJsonRestFilterKey(filter = {}, suffix = "") {
474
+ return [
475
+ JSON_REST_INTERNAL_FILTER_PREFIX,
476
+ filter.queryKey,
477
+ suffix
478
+ ]
479
+ .map((entry) => normalizeText(entry))
480
+ .filter(Boolean)
481
+ .join("_");
482
+ }
483
+
484
+ function createJsonRestSearchSchemaEntry({
485
+ type = "string",
486
+ actualField = "",
487
+ filterOperator = "=",
488
+ extra = {}
489
+ } = {}) {
490
+ return {
491
+ type,
492
+ ...(actualField ? { actualField } : {}),
493
+ ...(filterOperator ? { filterOperator } : {}),
494
+ ...extra
495
+ };
496
+ }
497
+
498
+ function buildJsonRestFilterItemSchema(filter = {}) {
499
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY) {
500
+ return {
501
+ type: "string",
502
+ enum: (Array.isArray(filter.options) ? filter.options : []).map((entry) => entry.value)
503
+ };
504
+ }
505
+
506
+ if (filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
507
+ return cloneTransportSchema(recordIdTransportSchema);
508
+ }
509
+
510
+ return {};
511
+ }
512
+
513
+ function buildJsonRestSimpleFilterSearchSchemaEntry(filter = {}, { actualField = "" } = {}) {
514
+ if (filter.type === CRUD_LIST_FILTER_TYPE_FLAG) {
515
+ return createJsonRestSearchSchemaEntry({
516
+ type: "boolean",
517
+ actualField,
518
+ filterOperator: "="
519
+ });
520
+ }
521
+
522
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM) {
523
+ return createJsonRestSearchSchemaEntry({
524
+ type: "string",
525
+ actualField,
526
+ filterOperator: "=",
527
+ extra: {
528
+ enum: (Array.isArray(filter.options) ? filter.options : []).map((entry) => entry.value)
529
+ }
530
+ });
531
+ }
532
+
533
+ if (filter.type === CRUD_LIST_FILTER_TYPE_ENUM_MANY || filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID_MANY) {
534
+ return createJsonRestSearchSchemaEntry({
535
+ type: "array",
536
+ actualField,
537
+ filterOperator: "in",
538
+ extra: {
539
+ items: buildJsonRestFilterItemSchema(filter)
540
+ }
541
+ });
542
+ }
543
+
544
+ if (filter.type === CRUD_LIST_FILTER_TYPE_RECORD_ID) {
545
+ return createJsonRestSearchSchemaEntry({
546
+ type: "id",
547
+ actualField,
548
+ filterOperator: "="
549
+ });
550
+ }
551
+
552
+ return null;
553
+ }
554
+
555
+ function buildJsonRestRangeFilterSearchSchemaEntries(filter = {}, { actualField = "" } = {}) {
556
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE) {
557
+ return {
558
+ [renderInternalJsonRestFilterKey(filter, "from")]: createJsonRestSearchSchemaEntry({
559
+ type: "dateTime",
560
+ actualField,
561
+ filterOperator: ">="
562
+ }),
563
+ [renderInternalJsonRestFilterKey(filter, "toExclusive")]: createJsonRestSearchSchemaEntry({
564
+ type: "dateTime",
565
+ actualField,
566
+ filterOperator: "<"
567
+ })
568
+ };
569
+ }
570
+
571
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
572
+ return {
573
+ [renderInternalJsonRestFilterKey(filter, "from")]: createJsonRestSearchSchemaEntry({
574
+ type: "dateTime",
575
+ actualField,
576
+ filterOperator: ">="
577
+ }),
578
+ [renderInternalJsonRestFilterKey(filter, "toExclusive")]: createJsonRestSearchSchemaEntry({
579
+ type: "dateTime",
580
+ actualField,
581
+ filterOperator: "<"
582
+ })
583
+ };
584
+ }
585
+
586
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
587
+ return {
588
+ [renderInternalJsonRestFilterKey(filter, "min")]: createJsonRestSearchSchemaEntry({
589
+ type: "number",
590
+ actualField,
591
+ filterOperator: ">="
592
+ }),
593
+ [renderInternalJsonRestFilterKey(filter, "max")]: createJsonRestSearchSchemaEntry({
594
+ type: "number",
595
+ actualField,
596
+ filterOperator: "<="
597
+ })
598
+ };
599
+ }
600
+
601
+ return {};
602
+ }
603
+
604
+ function buildJsonRestPresenceFilterSearchSchemaEntry(filter = {}, { actualField = "" } = {}) {
605
+ return {
606
+ type: "string",
607
+ enum: [CRUD_LIST_FILTER_PRESENCE_PRESENT, CRUD_LIST_FILTER_PRESENCE_MISSING],
608
+ applyFilter(queryBuilder, value) {
609
+ applyDefaultFilterQuery(queryBuilder, filter, value, actualField);
610
+ }
611
+ };
612
+ }
613
+
614
+ function buildJsonRestCustomFilterSearchSchemaEntry(filter = {}, customApply = null) {
615
+ return {
616
+ type: "none",
617
+ applyFilter(queryBuilder, value) {
618
+ customApply(queryBuilder, value, {
619
+ filter,
620
+ filters: Object.freeze({
621
+ [filter.key]: value
622
+ })
623
+ });
624
+ }
625
+ };
626
+ }
627
+
628
+ function resolveCrudListFilterEntries(value = {}) {
629
+ if (isPlainObject(value?.filters)) {
630
+ return Object.values(normalizeObject(value.filters));
631
+ }
632
+
633
+ return Object.values(defineCrudListFilters(value));
634
+ }
635
+
636
+ function createCrudListFilterJsonRestSearchSchema(runtime = {}, { columns = {}, apply = {} } = {}) {
637
+ const filterEntries = resolveCrudListFilterEntries(runtime);
638
+ const normalizedColumns = normalizeColumnsMap(columns);
639
+ const schema = {};
640
+
641
+ for (const filter of filterEntries) {
642
+ const customApply = typeof apply?.[filter.key] === "function" ? apply[filter.key] : null;
643
+ if (customApply) {
644
+ schema[filter.queryKey] = buildJsonRestCustomFilterSearchSchemaEntry(filter, customApply);
645
+ continue;
646
+ }
647
+
648
+ const actualField = resolveJsonRestFilterActualField(filter, normalizedColumns);
649
+ if (!actualField) {
650
+ continue;
651
+ }
652
+
653
+ const simpleEntry = buildJsonRestSimpleFilterSearchSchemaEntry(filter, { actualField });
654
+ if (simpleEntry) {
655
+ schema[filter.queryKey] = simpleEntry;
656
+ continue;
657
+ }
658
+
659
+ if (filter.type === CRUD_LIST_FILTER_TYPE_PRESENCE) {
660
+ schema[filter.queryKey] = buildJsonRestPresenceFilterSearchSchemaEntry(filter, { actualField });
661
+ continue;
662
+ }
663
+
664
+ Object.assign(schema, buildJsonRestRangeFilterSearchSchemaEntries(filter, { actualField }));
665
+ }
666
+
667
+ return Object.freeze(schema);
668
+ }
669
+
670
+ function addJsonRestDateFilterValues(target = {}, filter = {}, value = "") {
671
+ const nextDate = addDaysToDateFilterValue(value, 1);
672
+ if (value) {
673
+ target[renderInternalJsonRestFilterKey(filter, "from")] = `${value} 00:00:00`;
674
+ }
675
+ if (nextDate) {
676
+ target[renderInternalJsonRestFilterKey(filter, "toExclusive")] = `${nextDate} 00:00:00`;
677
+ }
678
+ }
679
+
680
+ function addJsonRestFilterValue(target = {}, filter = {}, value) {
681
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE) {
682
+ addJsonRestDateFilterValues(target, filter, value);
683
+ return;
684
+ }
685
+
686
+ if (filter.type === CRUD_LIST_FILTER_TYPE_DATE_RANGE) {
687
+ if (value?.from) {
688
+ target[renderInternalJsonRestFilterKey(filter, "from")] = `${value.from} 00:00:00`;
689
+ }
690
+ if (value?.to) {
691
+ const nextDate = addDaysToDateFilterValue(value.to, 1);
692
+ if (nextDate) {
693
+ target[renderInternalJsonRestFilterKey(filter, "toExclusive")] = `${nextDate} 00:00:00`;
694
+ }
695
+ }
696
+ return;
697
+ }
698
+
699
+ if (filter.type === CRUD_LIST_FILTER_TYPE_NUMBER_RANGE) {
700
+ if (value?.min != null) {
701
+ target[renderInternalJsonRestFilterKey(filter, "min")] = value.min;
702
+ }
703
+ if (value?.max != null) {
704
+ target[renderInternalJsonRestFilterKey(filter, "max")] = value.max;
705
+ }
706
+ return;
707
+ }
708
+
709
+ target[filter.queryKey] = value;
710
+ }
711
+
712
+ function normalizeCrudListFilterJsonRestQuery(runtime = {}, query = {}) {
713
+ const source = normalizeObjectInput(query);
714
+ const filterEntries = Object.values(normalizeObject(runtime?.filters));
715
+ const filterQueryKeys = new Set(filterEntries.map((filter) => filter.queryKey));
716
+ const parsedFilters = typeof runtime.parseQuery === "function"
717
+ ? runtime.parseQuery(source)
718
+ : {};
719
+ const normalized = {};
720
+
721
+ for (const [key, value] of Object.entries(source)) {
722
+ if (!filterQueryKeys.has(key)) {
723
+ normalized[key] = value;
724
+ }
725
+ }
726
+
727
+ for (const filter of filterEntries) {
728
+ if (!Object.hasOwn(parsedFilters, filter.key)) {
729
+ continue;
730
+ }
731
+
732
+ addJsonRestFilterValue(normalized, filter, parsedFilters[filter.key]);
733
+ }
734
+
735
+ return normalized;
736
+ }
737
+
465
738
  function applyDefaultFilterQuery(queryBuilder, filter = {}, value, column = "") {
466
739
  const handler = FILTER_TYPE_SERVER_HANDLERS[filter.type];
467
740
  return handler
@@ -529,14 +802,46 @@ function createCrudListFilters(definitions = {}, { columns = {}, apply = {} } =
529
802
  return Object.freeze({
530
803
  filters: normalizedFilters,
531
804
  createQueryValidator,
805
+ parseQuery: parseFilterPayload,
806
+ toJsonRestQuery(query = {}) {
807
+ return normalizeCrudListFilterJsonRestQuery({
808
+ filters: normalizedFilters,
809
+ parseQuery: parseFilterPayload
810
+ }, query);
811
+ },
812
+ jsonRestSearchSchema: createCrudListFilterJsonRestSearchSchema({
813
+ filters: normalizedFilters
814
+ }, {
815
+ columns: normalizedColumns,
816
+ apply
817
+ }),
532
818
  applyQuery
533
819
  });
534
820
  }
535
821
 
822
+ function createCrudListFilterContract(definitions = {}, {
823
+ columns = {},
824
+ apply = {},
825
+ invalidValues = CRUD_LIST_FILTER_INVALID_VALUES_REJECT
826
+ } = {}) {
827
+ const runtime = createCrudListFilters(definitions, { columns, apply });
828
+ return Object.freeze({
829
+ filters: runtime.filters,
830
+ queryValidator: runtime.createQueryValidator({ invalidValues }),
831
+ createQueryValidator: runtime.createQueryValidator,
832
+ parseQuery: runtime.parseQuery,
833
+ toJsonRestQuery: runtime.toJsonRestQuery,
834
+ jsonRestSearchSchema: runtime.jsonRestSearchSchema,
835
+ applyQuery: runtime.applyQuery
836
+ });
837
+ }
838
+
536
839
  export {
537
840
  CRUD_LIST_FILTER_INVALID_VALUES_REJECT,
538
841
  CRUD_LIST_FILTER_INVALID_VALUES_DISCARD,
539
842
  createCrudListFilterQueryField,
540
843
  createCrudListFilterQuerySchema,
541
- createCrudListFilters
844
+ createCrudListFilterJsonRestSearchSchema,
845
+ createCrudListFilters,
846
+ createCrudListFilterContract
542
847
  };
@@ -441,16 +441,19 @@ function createCrudJsonApiRouteContracts({
441
441
  resource = {},
442
442
  routeParamsValidator = null,
443
443
  listSearchQueryValidator = defaultListSearchQueryValidator,
444
- lookupIncludeQueryValidator = defaultLookupIncludeQueryValidator
444
+ lookupIncludeQueryValidator = defaultLookupIncludeQueryValidator,
445
+ listFilterQueryValidator = null
445
446
  } = {}) {
446
447
  const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator({
447
448
  orderBy: resource?.defaultSort
448
449
  });
449
450
  const listParentFilterQueryValidator = createCrudParentFilterQueryValidator(resource);
451
+ const resolvedListFilterQueryValidator = listFilterQueryValidator || resource?.contract?.listFilters?.queryValidator || null;
450
452
  const listRouteQueryValidator = composeSchemaDefinitions([
451
453
  listCursorPaginationQueryValidator,
452
454
  listSearchQueryValidator,
453
455
  listParentFilterQueryValidator,
456
+ ...(resolvedListFilterQueryValidator ? [resolvedListFilterQueryValidator] : []),
454
457
  lookupIncludeQueryValidator
455
458
  ]);
456
459
  const recordRouteParamsValidator = routeParamsValidator
@@ -13,7 +13,9 @@ import {
13
13
  CRUD_LIST_FILTER_INVALID_VALUES_DISCARD,
14
14
  createCrudListFilterQueryField,
15
15
  createCrudListFilterQuerySchema,
16
- createCrudListFilters
16
+ createCrudListFilterJsonRestSearchSchema,
17
+ createCrudListFilters,
18
+ createCrudListFilterContract
17
19
  } from "../src/server/listFilters.js";
18
20
 
19
21
  function composeSchemaDefinition(...definitions) {
@@ -28,6 +30,8 @@ test("crud-core exposes createCrudListFilters through the public package export"
28
30
  assert.equal(typeof module.createCrudListFilters, "function");
29
31
  assert.equal(typeof module.createCrudListFilterQueryField, "function");
30
32
  assert.equal(typeof module.createCrudListFilterQuerySchema, "function");
33
+ assert.equal(typeof module.createCrudListFilterJsonRestSearchSchema, "function");
34
+ assert.equal(typeof module.createCrudListFilterContract, "function");
31
35
  assert.equal(module.CRUD_LIST_FILTER_INVALID_VALUES_REJECT, CRUD_LIST_FILTER_INVALID_VALUES_REJECT);
32
36
  assert.equal(module.CRUD_LIST_FILTER_INVALID_VALUES_DISCARD, CRUD_LIST_FILTER_INVALID_VALUES_DISCARD);
33
37
  });
@@ -526,6 +530,154 @@ test("createCrudListFilters treats exact range filter values as exact bounds", (
526
530
  });
527
531
  });
528
532
 
533
+ test("createCrudListFilterContract builds route validators and JSON REST search schema from one definition", () => {
534
+ const contract = createCrudListFilterContract(
535
+ {
536
+ status: {
537
+ type: "enumMany",
538
+ label: "Status",
539
+ options: [
540
+ { value: "active", label: "Active" },
541
+ { value: "archived", label: "Archived" }
542
+ ]
543
+ },
544
+ supplierContactId: {
545
+ type: "recordIdMany",
546
+ label: "Supplier"
547
+ },
548
+ arrivalDate: {
549
+ type: "dateRange",
550
+ label: "Arrival Date"
551
+ },
552
+ weight: {
553
+ type: "numberRange",
554
+ label: "Weight"
555
+ },
556
+ locationAssignment: {
557
+ type: "presence",
558
+ label: "Storage"
559
+ }
560
+ },
561
+ {
562
+ columns: {
563
+ status: "status",
564
+ supplierContactId: "supplier_contact_id",
565
+ arrivalDate: "arrival_datetime",
566
+ weight: "weight_received",
567
+ locationAssignment: "location_id"
568
+ },
569
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_REJECT
570
+ }
571
+ );
572
+
573
+ assert.deepEqual(contract.queryValidator.schema.patch({
574
+ status: ["active", "archived"],
575
+ supplierContactId: ["7", "4"],
576
+ arrivalDate: "2026-04-01..2026-04-30",
577
+ weight: "12.5..18",
578
+ locationAssignment: "missing"
579
+ }), {
580
+ validatedObject: {
581
+ status: ["active", "archived"],
582
+ supplierContactId: ["7", "4"],
583
+ arrivalDate: {
584
+ from: "2026-04-01",
585
+ to: "2026-04-30"
586
+ },
587
+ weight: {
588
+ min: 12.5,
589
+ max: 18
590
+ },
591
+ locationAssignment: "missing"
592
+ },
593
+ errors: {}
594
+ });
595
+
596
+ assert.equal(contract.jsonRestSearchSchema.status.filterOperator, "in");
597
+ assert.equal(contract.jsonRestSearchSchema.status.actualField, "status");
598
+ assert.equal(contract.jsonRestSearchSchema.supplierContactId.filterOperator, "in");
599
+ assert.equal(contract.jsonRestSearchSchema.__jskit_arrivalDate_from.filterOperator, ">=");
600
+ assert.equal(contract.jsonRestSearchSchema.__jskit_arrivalDate_toExclusive.filterOperator, "<");
601
+ assert.equal(contract.jsonRestSearchSchema.__jskit_weight_min.filterOperator, ">=");
602
+ assert.equal(contract.jsonRestSearchSchema.__jskit_weight_max.filterOperator, "<=");
603
+ assert.equal(typeof contract.jsonRestSearchSchema.locationAssignment.applyFilter, "function");
604
+
605
+ assert.deepEqual(contract.toJsonRestQuery({
606
+ q: "Merc",
607
+ status: ["active", "archived"],
608
+ supplierContactId: ["7", "4"],
609
+ arrivalDate: "2026-04-01..2026-04-30",
610
+ weight: "12.5..",
611
+ locationAssignment: "missing"
612
+ }), {
613
+ q: "Merc",
614
+ status: ["active", "archived"],
615
+ supplierContactId: ["7", "4"],
616
+ locationAssignment: "missing",
617
+ __jskit_arrivalDate_from: "2026-04-01 00:00:00",
618
+ __jskit_arrivalDate_toExclusive: "2026-05-01 00:00:00",
619
+ __jskit_weight_min: 12.5
620
+ });
621
+
622
+ const { query, calls } = createQueryDouble();
623
+ contract.jsonRestSearchSchema.locationAssignment.applyFilter(query, "missing");
624
+ assert.deepEqual(calls, [["whereNull", "location_id"]]);
625
+ });
626
+
627
+ test("createCrudListFilterContract defaults route validation to reject invalid values", () => {
628
+ const contract = createCrudListFilterContract({
629
+ status: {
630
+ type: "enum",
631
+ label: "Status",
632
+ options: [
633
+ { value: "active", label: "Active" }
634
+ ]
635
+ }
636
+ });
637
+
638
+ assert.deepEqual(contract.queryValidator.schema.patch({
639
+ status: "unexpected"
640
+ }), {
641
+ validatedObject: {
642
+ status: "unexpected"
643
+ },
644
+ errors: {
645
+ status: {
646
+ field: "status",
647
+ code: "TYPE_CAST_FAILED",
648
+ message: "Value could not be cast to the required type.",
649
+ params: {}
650
+ }
651
+ }
652
+ });
653
+ });
654
+
655
+ test("createCrudListFilterJsonRestSearchSchema accepts normalized filter definitions directly", () => {
656
+ const filters = defineCrudListFilters({
657
+ status: {
658
+ type: "enum",
659
+ label: "Status",
660
+ options: [
661
+ { value: "active", label: "Active" }
662
+ ],
663
+ meta: {
664
+ jsonRest: {
665
+ actualField: "status_sid"
666
+ }
667
+ }
668
+ }
669
+ });
670
+
671
+ assert.deepEqual(createCrudListFilterJsonRestSearchSchema(filters), {
672
+ status: {
673
+ type: "string",
674
+ actualField: "status_sid",
675
+ filterOperator: "=",
676
+ enum: ["active"]
677
+ }
678
+ });
679
+ });
680
+
529
681
  test("createCrudListFilters exposes no default query validator alias", () => {
530
682
  const runtime = createCrudListFilters({
531
683
  arrivalDate: {
@@ -5,6 +5,7 @@ import {
5
5
  validateSchemaPayload
6
6
  } from "@jskit-ai/kernel/shared/validators";
7
7
  import { returnJsonApiData } from "@jskit-ai/http-runtime/shared/validators/jsonApiResult";
8
+ import { createCrudListFilterContract } from "../src/server/listFilters.js";
8
9
  import { createCrudJsonApiRouteContracts } from "../src/server/routeContracts.js";
9
10
 
10
11
  function createSchemaDefinition(structure = {}, mode = "patch") {
@@ -304,6 +305,57 @@ test("createCrudJsonApiRouteContracts builds default CRUD JSON:API contracts", a
304
305
  ]);
305
306
  });
306
307
 
308
+ test("createCrudJsonApiRouteContracts includes configured list filter validators", async () => {
309
+ const listFilterContract = createCrudListFilterContract({
310
+ status: {
311
+ type: "enumMany",
312
+ label: "Status",
313
+ options: [
314
+ { value: "active", label: "Active" },
315
+ { value: "archived", label: "Archived" }
316
+ ]
317
+ }
318
+ });
319
+
320
+ const contracts = createCrudJsonApiRouteContracts({
321
+ resource: createCrudResource(),
322
+ listFilterQueryValidator: listFilterContract.queryValidator
323
+ });
324
+
325
+ assert.deepEqual(await validateSchemaPayload(contracts.listRouteContract.query, {
326
+ status: ["active", "archived"]
327
+ }, { phase: "input" }), {
328
+ status: ["active", "archived"]
329
+ });
330
+ });
331
+
332
+ test("createCrudJsonApiRouteContracts reads list filter validators from the resource contract", async () => {
333
+ const listFilterContract = createCrudListFilterContract({
334
+ status: {
335
+ type: "enum",
336
+ label: "Status",
337
+ options: [
338
+ { value: "active", label: "Active" },
339
+ { value: "archived", label: "Archived" }
340
+ ]
341
+ }
342
+ });
343
+ const resource = {
344
+ ...createCrudResource(),
345
+ contract: {
346
+ listFilters: listFilterContract
347
+ }
348
+ };
349
+
350
+ const contracts = createCrudJsonApiRouteContracts({ resource });
351
+
352
+ assert.deepEqual(await validateSchemaPayload(contracts.listRouteContract.query, {
353
+ status: "archived"
354
+ }, { phase: "input" }), {
355
+ status: "archived"
356
+ });
357
+ });
358
+
307
359
  test("createCrudJsonApiRouteContracts serializes collection relationships from hydrated lookups", () => {
308
360
  const output = createSchemaDefinition({
309
361
  id: {