@jskit-ai/crud-core 0.1.63 → 0.1.65

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.
@@ -0,0 +1,97 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createSchema, validateSchemaPayload } from "@jskit-ai/kernel/shared/validators";
4
+ import { defineCrudResource } from "../src/shared/crudResource.js";
5
+
6
+ function createContactsResource(overrides = {}) {
7
+ return defineCrudResource({
8
+ namespace: "contacts",
9
+ schema: {
10
+ name: {
11
+ type: "string",
12
+ maxLength: 190,
13
+ operations: {
14
+ output: { required: true },
15
+ create: { required: true },
16
+ patch: { required: false }
17
+ }
18
+ },
19
+ createdAt: {
20
+ type: "dateTime",
21
+ operations: {
22
+ output: { required: true }
23
+ }
24
+ }
25
+ },
26
+ contract: {
27
+ lookup: {
28
+ containerKey: "lookups"
29
+ }
30
+ },
31
+ ...overrides
32
+ });
33
+ }
34
+
35
+ test("defineCrudResource derives full CRUD operations by default", async () => {
36
+ const resource = createContactsResource();
37
+
38
+ assert.deepEqual(
39
+ Object.keys(resource.operations),
40
+ ["list", "view", "create", "patch", "delete"]
41
+ );
42
+ assert.deepEqual(resource.operations.list.realtime?.events, ["contacts.record.changed"]);
43
+
44
+ const normalizedCreateBody = await validateSchemaPayload(resource.operations.create.body, {
45
+ name: " Example "
46
+ }, { phase: "input" });
47
+ assert.equal(normalizedCreateBody.name, "Example");
48
+
49
+ const normalizedViewOutput = await validateSchemaPayload(resource.operations.view.output, {
50
+ id: 7,
51
+ name: " Example ",
52
+ createdAt: "2026-05-01 12:30:00.000",
53
+ lookups: {}
54
+ }, { phase: "output" });
55
+ assert.equal(normalizedViewOutput.id, "7");
56
+ assert.equal(normalizedViewOutput.name, "Example");
57
+ assert.ok(normalizedViewOutput.createdAt instanceof Date);
58
+ });
59
+
60
+ test("defineCrudResource supports an explicit standard CRUD operation subset", () => {
61
+ const resource = createContactsResource({
62
+ crudOperations: ["list", "view", "create"]
63
+ });
64
+
65
+ assert.deepEqual(
66
+ Object.keys(resource.operations),
67
+ ["list", "view", "create"]
68
+ );
69
+ assert.equal(Object.hasOwn(resource, "crudOperations"), false);
70
+ });
71
+
72
+ test("defineCrudResource merges authored operation overrides into derived defaults", () => {
73
+ const resource = createContactsResource({
74
+ operations: {
75
+ list: {
76
+ realtime: {
77
+ events: ["contacts.custom.changed"]
78
+ }
79
+ },
80
+ archive: {
81
+ method: "POST",
82
+ output: Object.freeze({
83
+ mode: "replace",
84
+ schema: createSchema({
85
+ archived: {
86
+ type: "boolean",
87
+ required: true
88
+ }
89
+ })
90
+ })
91
+ }
92
+ }
93
+ });
94
+
95
+ assert.deepEqual(resource.operations.list.realtime?.events, ["contacts.custom.changed"]);
96
+ assert.equal(resource.operations.archive.method, "POST");
97
+ });
@@ -1,22 +1,51 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { Check } from "typebox/value";
3
+ import { createSchema } from "json-rest-schema";
4
4
  import { compileRouteValidator } from "@jskit-ai/kernel/_testable";
5
- import { cursorPaginationQueryValidator } from "@jskit-ai/kernel/shared/validators";
5
+ import { defineCrudListFilters } from "@jskit-ai/kernel/shared/support/crudListFilters";
6
+ import {
7
+ composeSchemaDefinitions,
8
+ cursorPaginationQueryValidator
9
+ } from "@jskit-ai/kernel/shared/validators";
6
10
  import { listSearchQueryValidator } from "../src/server/listQueryValidators.js";
7
11
  import {
8
12
  CRUD_LIST_FILTER_INVALID_VALUES_REJECT,
9
13
  CRUD_LIST_FILTER_INVALID_VALUES_DISCARD,
14
+ createCrudListFilterQueryField,
15
+ createCrudListFilterQuerySchema,
10
16
  createCrudListFilters
11
17
  } from "../src/server/listFilters.js";
12
18
 
19
+ function composeSchemaDefinition(...definitions) {
20
+ return composeSchemaDefinitions(definitions, {
21
+ mode: "patch",
22
+ context: "crudCore.listFilters.compose"
23
+ });
24
+ }
25
+
13
26
  test("crud-core exposes createCrudListFilters through the public package export", async () => {
14
27
  const module = await import("@jskit-ai/crud-core/server/listFilters");
15
28
  assert.equal(typeof module.createCrudListFilters, "function");
29
+ assert.equal(typeof module.createCrudListFilterQueryField, "function");
30
+ assert.equal(typeof module.createCrudListFilterQuerySchema, "function");
16
31
  assert.equal(module.CRUD_LIST_FILTER_INVALID_VALUES_REJECT, CRUD_LIST_FILTER_INVALID_VALUES_REJECT);
17
32
  assert.equal(module.CRUD_LIST_FILTER_INVALID_VALUES_DISCARD, CRUD_LIST_FILTER_INVALID_VALUES_DISCARD);
18
33
  });
19
34
 
35
+ test("importing crud-core list filters does not register a global json-rest-schema type", () => {
36
+ const schema = createSchema({
37
+ status: {
38
+ type: "crudListFilterQuery"
39
+ }
40
+ });
41
+
42
+ assert.throws(() => {
43
+ schema.patch({
44
+ status: "active"
45
+ });
46
+ }, /No casting function for type: crudListFilterQuery/);
47
+ });
48
+
20
49
  function createQueryDouble() {
21
50
  const calls = [];
22
51
  const nestedQuery = {
@@ -69,7 +98,7 @@ function createQueryDouble() {
69
98
  };
70
99
  }
71
100
 
72
- test("createCrudListFilters normalizes filters into semantic values", () => {
101
+ test("createCrudListFilters parses filters into semantic values", () => {
73
102
  const runtime = createCrudListFilters({
74
103
  onlyStaff: {
75
104
  type: "flag",
@@ -97,28 +126,33 @@ test("createCrudListFilters normalizes filters into semantic values", () => {
97
126
  }
98
127
  });
99
128
 
100
- const normalized = runtime.normalize({
129
+ const validator = runtime.createQueryValidator({
130
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_DISCARD
131
+ });
132
+
133
+ const result = validator.schema.patch({
101
134
  onlyStaff: "",
102
135
  status: ["active", "ignored", "archived"],
103
- arrivalDateFrom: "2026-04-01",
104
- arrivalDateTo: "2026-04-30",
136
+ arrivalDate: "2026-04-01..2026-04-30",
105
137
  supplierContactId: ["7", "bad", "4"],
106
- weightMin: "12.5",
107
- weightMax: 18
138
+ weight: "12.5..18"
108
139
  });
109
140
 
110
- assert.deepEqual(normalized, {
111
- onlyStaff: true,
112
- status: ["active", "archived"],
113
- arrivalDate: {
114
- from: "2026-04-01",
115
- to: "2026-04-30"
141
+ assert.deepEqual(result, {
142
+ validatedObject: {
143
+ onlyStaff: true,
144
+ status: ["active", "archived"],
145
+ arrivalDate: {
146
+ from: "2026-04-01",
147
+ to: "2026-04-30"
148
+ },
149
+ supplierContactId: ["7", "4"],
150
+ weight: {
151
+ min: 12.5,
152
+ max: 18
153
+ }
116
154
  },
117
- supplierContactId: ["7", "4"],
118
- weight: {
119
- min: 12.5,
120
- max: 18
121
- }
155
+ errors: {}
122
156
  });
123
157
  });
124
158
 
@@ -171,10 +205,8 @@ test("createCrudListFilters applies default column filters by type", () => {
171
205
  onlyStaff: "",
172
206
  status: ["active", "archived"],
173
207
  supplierContactId: "7",
174
- arrivalDateFrom: "2026-04-01",
175
- arrivalDateTo: "2026-04-30",
176
- weightMin: "12.5",
177
- weightMax: "18",
208
+ arrivalDate: "2026-04-01..2026-04-30",
209
+ weight: "12.5..18",
178
210
  locationAssignment: "missing"
179
211
  });
180
212
 
@@ -248,16 +280,87 @@ test("createCrudListFilters query validator stays mergeable with search and curs
248
280
  });
249
281
 
250
282
  const compiled = compileRouteValidator({
251
- queryValidator: [
283
+ query: composeSchemaDefinition(
252
284
  cursorPaginationQueryValidator,
253
285
  listSearchQueryValidator,
254
286
  runtime.createQueryValidator({
255
287
  invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_REJECT
256
288
  })
257
- ]
289
+ )
290
+ });
291
+
292
+ assert.deepEqual(compiled.schema.querystring.required || [], []);
293
+ });
294
+
295
+ test("createCrudListFilterQueryField keeps route query params explicit while reusing filter semantics", () => {
296
+ const filters = defineCrudListFilters({
297
+ status: {
298
+ type: "enum",
299
+ label: "Status",
300
+ options: [
301
+ { value: "active", label: "Active" },
302
+ { value: "archived", label: "Archived" }
303
+ ]
304
+ },
305
+ arrivalDate: {
306
+ type: "dateRange",
307
+ label: "Arrival Date"
308
+ }
309
+ });
310
+
311
+ const explicitFilterQueryValidator = Object.freeze({
312
+ schema: createCrudListFilterQuerySchema({
313
+ status: createCrudListFilterQueryField(filters.status, {
314
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_REJECT
315
+ }),
316
+ arrivalDate: createCrudListFilterQueryField(filters.arrivalDate, {
317
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_REJECT
318
+ })
319
+ }),
320
+ mode: "patch"
321
+ });
322
+
323
+ const compiled = compileRouteValidator({
324
+ query: composeSchemaDefinition(
325
+ cursorPaginationQueryValidator,
326
+ listSearchQueryValidator,
327
+ explicitFilterQueryValidator
328
+ )
329
+ });
330
+ const transportSchema = explicitFilterQueryValidator.schema.toJsonSchema({
331
+ mode: explicitFilterQueryValidator.mode
258
332
  });
259
333
 
260
334
  assert.deepEqual(compiled.schema.querystring.required || [], []);
335
+ assert.equal(transportSchema.type, "object");
336
+ assert.equal(transportSchema.properties.status.enum[0], "active");
337
+ assert.equal(
338
+ transportSchema.properties.arrivalDate.pattern,
339
+ "^(?:\\d{4}-\\d{2}-\\d{2}(?:\\.\\.(?:\\d{4}-\\d{2}-\\d{2})?)?|\\.\\.\\d{4}-\\d{2}-\\d{2})$"
340
+ );
341
+ assert.deepEqual(explicitFilterQueryValidator.schema.patch({
342
+ status: "active",
343
+ arrivalDate: "2026-04-01..2026-04-30"
344
+ }), {
345
+ validatedObject: {
346
+ status: "active",
347
+ arrivalDate: {
348
+ from: "2026-04-01",
349
+ to: "2026-04-30"
350
+ }
351
+ },
352
+ errors: {}
353
+ });
354
+ });
355
+
356
+ test("createCrudListFilterQueryField requires normalized filter definitions", () => {
357
+ assert.throws(
358
+ () => createCrudListFilterQueryField({
359
+ type: "enum",
360
+ label: "Status"
361
+ }),
362
+ /normalized filter definition/
363
+ );
261
364
  });
262
365
 
263
366
  test("createCrudListFilters requires explicit invalid-value mode for new query validators", () => {
@@ -296,28 +399,45 @@ test("createCrudListFilters reject validator keeps strict filter schemas", () =>
296
399
  const validator = runtime.createQueryValidator({
297
400
  invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_REJECT
298
401
  });
402
+ const transportSchema = validator.schema.toJsonSchema({
403
+ mode: validator.mode
404
+ });
299
405
 
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);
406
+ assert.equal(transportSchema.type, "object");
407
+ assert.equal(transportSchema.additionalProperties, false);
408
+ assert.equal(
409
+ transportSchema.properties.arrivalDate.pattern,
410
+ "^(?:\\d{4}-\\d{2}-\\d{2}(?:\\.\\.(?:\\d{4}-\\d{2}-\\d{2})?)?|\\.\\.\\d{4}-\\d{2}-\\d{2})$"
411
+ );
412
+ assert.deepEqual(transportSchema.properties.status.anyOf[1].items.enum, ["active", "archived"]);
413
+ assert.equal(transportSchema.properties.supplierContactId.anyOf[1].items.anyOf[0].pattern, "^[1-9][0-9]*$");
414
+ assert.equal(
415
+ transportSchema.properties.weight.anyOf[0].pattern,
416
+ "^(?:[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?(?:\\.\\.(?:[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?)?)?|\\.\\.[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?)$"
417
+ );
418
+ assert.deepEqual(validator.schema.patch({
419
+ arrivalDate: "2026-04-01..2026-04-30",
420
+ status: ["active", "archived"],
421
+ supplierContactId: ["7", "4"],
422
+ weight: "12.5..18"
423
+ }), {
424
+ validatedObject: {
425
+ arrivalDate: {
426
+ from: "2026-04-01",
427
+ to: "2026-04-30"
428
+ },
429
+ status: ["active", "archived"],
430
+ supplierContactId: ["7", "4"],
431
+ weight: {
432
+ min: 12.5,
433
+ max: 18
434
+ }
435
+ },
436
+ errors: {}
437
+ });
318
438
  });
319
439
 
320
- test("createCrudListFilters discard validator accepts malformed values and lets normalize drop them", () => {
440
+ test("createCrudListFilters discard validator returns canonical partial values directly", () => {
321
441
  const runtime = createCrudListFilters({
322
442
  arrivalDate: {
323
443
  type: "dateRange",
@@ -344,25 +464,65 @@ test("createCrudListFilters discard validator accepts malformed values and lets
344
464
  const validator = runtime.createQueryValidator({
345
465
  invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_DISCARD
346
466
  });
467
+ const transportSchema = validator.schema.toJsonSchema({
468
+ mode: validator.mode
469
+ });
347
470
 
348
- assert.equal(Check(validator.schema, {
349
- arrivalDateFrom: "bad-date",
471
+ assert.equal(transportSchema.type, "object");
472
+ assert.equal(transportSchema.properties.arrivalDate.minLength, 0);
473
+ assert.equal(transportSchema.properties.status.anyOf[0].minLength, 0);
474
+ assert.equal(transportSchema.properties.supplierContactId.anyOf[0].anyOf[0].minLength, 0);
475
+ assert.deepEqual(validator.schema.patch({
476
+ arrivalDate: "bad-date..2026-04-30",
350
477
  status: ["active", "unexpected"],
351
478
  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"
479
+ weight: "bad..18"
360
480
  }), {
481
+ validatedObject: {
482
+ arrivalDate: {
483
+ to: "2026-04-30"
484
+ },
485
+ status: ["active"],
486
+ supplierContactId: ["7"],
487
+ weight: {
488
+ max: 18
489
+ }
490
+ },
491
+ errors: {}
492
+ });
493
+ });
494
+
495
+ test("createCrudListFilters treats exact range filter values as exact bounds", () => {
496
+ const runtime = createCrudListFilters({
361
497
  arrivalDate: {
362
- to: "2026-04-30"
498
+ type: "dateRange",
499
+ label: "Arrival Date"
500
+ },
501
+ weight: {
502
+ type: "numberRange",
503
+ label: "Weight"
504
+ }
505
+ });
506
+
507
+ const validator = runtime.createQueryValidator({
508
+ invalidValues: CRUD_LIST_FILTER_INVALID_VALUES_DISCARD
509
+ });
510
+
511
+ assert.deepEqual(validator.schema.patch({
512
+ arrivalDate: "2026-04-18",
513
+ weight: 12.5
514
+ }), {
515
+ validatedObject: {
516
+ arrivalDate: {
517
+ from: "2026-04-18",
518
+ to: "2026-04-18"
519
+ },
520
+ weight: {
521
+ min: 12.5,
522
+ max: 12.5
523
+ }
363
524
  },
364
- status: ["active"],
365
- supplierContactId: ["7"]
525
+ errors: {}
366
526
  });
367
527
  });
368
528
 
@@ -374,6 +534,8 @@ test("createCrudListFilters exposes no default query validator alias", () => {
374
534
  }
375
535
  });
376
536
 
377
- assert.equal(Object.hasOwn(runtime, "queryValidator"), false);
378
- assert.equal(runtime.queryValidator, undefined);
537
+ assert.equal(Object.hasOwn(runtime, "query"), false);
538
+ assert.equal(runtime.query, undefined);
539
+ assert.equal(Object.hasOwn(runtime, "normalize"), false);
540
+ assert.equal(runtime.normalize, undefined);
379
541
  });