@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.
@@ -1,18 +1,18 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
+ import { createSchema } from "json-rest-schema";
3
4
  import { RECORD_ID_PATTERN } from "@jskit-ai/kernel/shared/validators";
4
5
  import { createCrudResourceRuntime } from "../src/server/resourceRuntime/index.js";
5
6
 
6
7
  const recordIdSchema = Object.freeze({
7
8
  type: "string",
9
+ minLength: 1,
8
10
  pattern: RECORD_ID_PATTERN
9
11
  });
10
12
 
11
13
  const nullableRecordIdSchema = Object.freeze({
12
- anyOf: [
13
- recordIdSchema,
14
- { type: "null" }
15
- ]
14
+ ...recordIdSchema,
15
+ nullable: true
16
16
  });
17
17
 
18
18
  function createKnexDouble(
@@ -202,33 +202,32 @@ function createResourceFixture() {
202
202
  idColumn: "contact_id",
203
203
  operations: {
204
204
  view: {
205
- outputValidator: {
206
- schema: {
207
- type: "object",
208
- properties: {
209
- id: recordIdSchema,
210
- firstName: { type: "string" }
205
+ output: {
206
+ schema: createSchema({
207
+ id: {
208
+ ...recordIdSchema,
209
+ required: true,
210
+ actualField: "contact_id"
211
+ },
212
+ firstName: {
213
+ type: "string",
214
+ required: true
211
215
  }
212
- }
216
+ }),
217
+ mode: "replace"
213
218
  }
214
219
  },
215
220
  create: {
216
- bodyValidator: {
217
- schema: {
218
- type: "object",
219
- properties: {
220
- firstName: { type: "string" }
221
+ body: {
222
+ schema: createSchema({
223
+ firstName: {
224
+ type: "string"
221
225
  }
222
- }
226
+ }),
227
+ mode: "create"
223
228
  }
224
229
  }
225
- },
226
- fieldMeta: [
227
- {
228
- key: "id",
229
- repository: { column: "contact_id" }
230
- }
231
- ]
230
+ }
232
231
  };
233
232
  }
234
233
 
@@ -239,56 +238,53 @@ function createLookupResourceFixture() {
239
238
  idColumn: "contact_id",
240
239
  operations: {
241
240
  view: {
242
- outputValidator: {
243
- schema: {
244
- type: "object",
245
- properties: {
246
- id: recordIdSchema,
247
- firstName: { type: "string" },
248
- primaryVetId: recordIdSchema,
249
- secondaryVetId: nullableRecordIdSchema,
250
- lookups: {
251
- type: "object"
241
+ output: {
242
+ schema: createSchema({
243
+ id: {
244
+ ...recordIdSchema,
245
+ required: true,
246
+ actualField: "contact_id"
247
+ },
248
+ firstName: {
249
+ type: "string",
250
+ required: true
251
+ },
252
+ primaryVetId: {
253
+ ...recordIdSchema,
254
+ actualField: "primary_vet_id",
255
+ relation: {
256
+ kind: "lookup",
257
+ namespace: "vets",
258
+ valueKey: "id"
252
259
  }
260
+ },
261
+ secondaryVetId: {
262
+ ...nullableRecordIdSchema,
263
+ actualField: "secondary_vet_id",
264
+ relation: {
265
+ kind: "lookup",
266
+ namespace: "vets",
267
+ valueKey: "id"
268
+ }
269
+ },
270
+ lookups: {
271
+ type: "object"
253
272
  }
254
- }
273
+ }),
274
+ mode: "replace"
255
275
  }
256
276
  },
257
277
  create: {
258
- bodyValidator: {
259
- schema: {
260
- type: "object",
261
- properties: {
262
- firstName: { type: "string" }
278
+ body: {
279
+ schema: createSchema({
280
+ firstName: {
281
+ type: "string"
263
282
  }
264
- }
265
- }
266
- }
267
- },
268
- fieldMeta: [
269
- {
270
- key: "id",
271
- repository: { column: "contact_id" }
272
- },
273
- {
274
- key: "primaryVetId",
275
- repository: { column: "primary_vet_id" },
276
- relation: {
277
- kind: "lookup",
278
- namespace: "vets",
279
- valueKey: "id"
280
- }
281
- },
282
- {
283
- key: "secondaryVetId",
284
- repository: { column: "secondary_vet_id" },
285
- relation: {
286
- kind: "lookup",
287
- namespace: "vets",
288
- valueKey: "id"
283
+ }),
284
+ mode: "create"
289
285
  }
290
286
  }
291
- ]
287
+ }
292
288
  };
293
289
  }
294
290
 
@@ -299,35 +295,29 @@ function createLeafLookupResourceFixture() {
299
295
  idColumn: "user_id",
300
296
  operations: {
301
297
  view: {
302
- outputValidator: {
303
- schema: {
304
- type: "object",
305
- properties: {
306
- id: recordIdSchema,
307
- name: { type: "string" }
298
+ output: {
299
+ schema: createSchema({
300
+ id: {
301
+ ...recordIdSchema,
302
+ required: true,
303
+ actualField: "user_id"
304
+ },
305
+ name: {
306
+ type: "string",
307
+ required: true,
308
+ actualField: "display_name"
308
309
  }
309
- }
310
+ }),
311
+ mode: "replace"
310
312
  }
311
313
  },
312
314
  create: {
313
- bodyValidator: {
314
- schema: {
315
- type: "object",
316
- properties: {}
317
- }
315
+ body: {
316
+ schema: createSchema({}),
317
+ mode: "create"
318
318
  }
319
319
  }
320
- },
321
- fieldMeta: [
322
- {
323
- key: "id",
324
- repository: { column: "user_id" }
325
- },
326
- {
327
- key: "name",
328
- repository: { column: "display_name" }
329
- }
330
- ]
320
+ }
331
321
  };
332
322
  }
333
323
 
@@ -338,89 +328,54 @@ function createNormalizedWriteResourceFixture() {
338
328
  idColumn: "contact_id",
339
329
  operations: {
340
330
  view: {
341
- outputValidator: {
342
- schema: {
343
- type: "object",
344
- properties: {
345
- id: recordIdSchema,
346
- firstName: { type: "string" },
347
- lastSeenAt: {
348
- anyOf: [
349
- { type: "string" },
350
- { type: "null" }
351
- ]
352
- }
331
+ output: {
332
+ schema: createSchema({
333
+ id: {
334
+ ...recordIdSchema,
335
+ required: true,
336
+ actualField: "contact_id"
337
+ },
338
+ firstName: {
339
+ type: "string",
340
+ required: true,
341
+ actualField: "first_name"
353
342
  }
354
- }
343
+ }),
344
+ mode: "replace"
355
345
  }
356
346
  },
357
347
  create: {
358
- bodyValidator: {
359
- schema: {
360
- type: "object",
361
- properties: {
362
- firstName: { type: "string" }
363
- }
364
- },
365
- normalize(payload = {}) {
366
- if (payload.firstName === "bad-create") {
367
- const error = new Error("Validation failed.");
368
- error.details = {
369
- fieldErrors: {
370
- firstName: "Invalid create value."
371
- }
372
- };
373
- throw error;
348
+ body: {
349
+ schema: createSchema({
350
+ firstName: {
351
+ type: "string",
352
+ required: true,
353
+ minLength: 1,
354
+ actualField: "first_name"
374
355
  }
375
-
376
- return {
377
- ...payload,
378
- firstName: String(payload.firstName || "").trim()
379
- };
380
- }
356
+ }),
357
+ mode: "create"
381
358
  }
382
359
  },
383
360
  patch: {
384
- bodyValidator: {
385
- schema: {
386
- type: "object",
387
- properties: {
388
- firstName: { type: "string" },
389
- lastSeenAt: {
390
- anyOf: [
391
- { type: "string" },
392
- { type: "null" }
393
- ]
394
- }
395
- }
396
- },
397
- normalize(payload = {}, context = {}) {
398
- if (payload.firstName === "bad-update") {
399
- const error = new Error("Validation failed.");
400
- error.details = {
401
- fieldErrors: {
402
- firstName: "Invalid update value."
403
- }
404
- };
405
- throw error;
361
+ body: {
362
+ schema: createSchema({
363
+ firstName: {
364
+ type: "string",
365
+ required: false,
366
+ minLength: 1,
367
+ actualField: "first_name"
368
+ },
369
+ lastSeenAt: {
370
+ type: "string",
371
+ required: false,
372
+ actualField: "last_seen_at"
406
373
  }
407
-
408
- return {
409
- ...payload,
410
- firstName: `${String(payload.firstName || "").trim()}-${context.existingRecord?.firstName || ""}`,
411
- ...(Object.hasOwn(payload, "lastSeenAt")
412
- ? { lastSeenAt: String(payload.lastSeenAt ?? "").trim() || null }
413
- : {})
414
- };
415
- }
374
+ }),
375
+ mode: "patch"
416
376
  }
417
377
  }
418
- },
419
- fieldMeta: [
420
- { key: "id", repository: { column: "contact_id" } },
421
- { key: "firstName", repository: { column: "first_name" } },
422
- { key: "lastSeenAt", repository: { column: "last_seen_at" } }
423
- ]
378
+ }
424
379
  };
425
380
  }
426
381
 
@@ -431,40 +386,38 @@ function createVirtualProjectionResourceFixture() {
431
386
  idColumn: "receival_id",
432
387
  operations: {
433
388
  view: {
434
- outputValidator: {
435
- schema: {
436
- type: "object",
437
- properties: {
438
- id: recordIdSchema,
439
- firstName: { type: "string" },
440
- remainingBatchWeight: { type: "number" }
389
+ output: {
390
+ schema: createSchema({
391
+ id: {
392
+ ...recordIdSchema,
393
+ required: true,
394
+ actualField: "receival_id"
395
+ },
396
+ firstName: {
397
+ type: "string",
398
+ required: true
399
+ },
400
+ remainingBatchWeight: {
401
+ type: "number",
402
+ storage: {
403
+ virtual: true
404
+ }
441
405
  }
442
- }
406
+ }),
407
+ mode: "replace"
443
408
  }
444
409
  },
445
410
  create: {
446
- bodyValidator: {
447
- schema: {
448
- type: "object",
449
- properties: {
450
- firstName: { type: "string" }
411
+ body: {
412
+ schema: createSchema({
413
+ firstName: {
414
+ type: "string"
451
415
  }
452
- }
453
- }
454
- }
455
- },
456
- fieldMeta: [
457
- {
458
- key: "id",
459
- repository: { column: "receival_id" }
460
- },
461
- {
462
- key: "remainingBatchWeight",
463
- repository: {
464
- storage: "virtual"
416
+ }),
417
+ mode: "create"
465
418
  }
466
419
  }
467
- ]
420
+ }
468
421
  };
469
422
  }
470
423
 
@@ -475,25 +428,23 @@ test("createCrudResourceRuntime requires table metadata from resource", () => {
475
428
  createCrudResourceRuntime({
476
429
  operations: {
477
430
  view: {
478
- outputValidator: {
479
- schema: {
480
- type: "object",
481
- properties: {
482
- id: recordIdSchema
431
+ output: {
432
+ schema: createSchema({
433
+ id: {
434
+ ...recordIdSchema,
435
+ required: true
483
436
  }
484
- }
437
+ }),
438
+ mode: "replace"
485
439
  }
486
440
  },
487
441
  create: {
488
- bodyValidator: {
489
- schema: {
490
- type: "object",
491
- properties: {}
492
- }
442
+ body: {
443
+ schema: createSchema({}),
444
+ mode: "create"
493
445
  }
494
446
  }
495
- },
496
- fieldMeta: []
447
+ }
497
448
  }, knex),
498
449
  /requires resource\.tableName or resource\.namespace/
499
450
  );
@@ -774,28 +725,26 @@ test("listByIds supports alternate valueKey and listByForeignIds delegates to it
774
725
  operations: {
775
726
  ...createResourceFixture().operations,
776
727
  view: {
777
- outputValidator: {
778
- schema: {
779
- type: "object",
780
- properties: {
781
- id: recordIdSchema,
782
- foreignId: recordIdSchema,
783
- firstName: { type: "string" }
728
+ output: {
729
+ schema: createSchema({
730
+ id: {
731
+ ...recordIdSchema,
732
+ required: true,
733
+ actualField: "contact_id"
734
+ },
735
+ foreignId: {
736
+ ...recordIdSchema,
737
+ actualField: "foreign_id"
738
+ },
739
+ firstName: {
740
+ type: "string",
741
+ required: true
784
742
  }
785
- }
743
+ }),
744
+ mode: "replace"
786
745
  }
787
746
  }
788
- },
789
- fieldMeta: [
790
- {
791
- key: "id",
792
- repository: { column: "contact_id" }
793
- },
794
- {
795
- key: "foreignId",
796
- repository: { column: "foreign_id" }
797
747
  }
798
- ]
799
748
  };
800
749
  const { knex, calls } = createKnexDouble([
801
750
  {
@@ -833,35 +782,42 @@ test("create uses operations.create.prepareInsertPayload before insert", async (
833
782
  idColumn: "contact_id",
834
783
  operations: {
835
784
  view: {
836
- outputValidator: {
837
- schema: {
838
- type: "object",
839
- properties: {
840
- id: recordIdSchema,
841
- firstName: { type: "string" },
842
- createdAt: { type: "string" },
843
- updatedAt: { type: "string" }
785
+ output: {
786
+ schema: createSchema({
787
+ id: {
788
+ ...recordIdSchema,
789
+ required: true,
790
+ actualField: "contact_id"
791
+ },
792
+ firstName: {
793
+ type: "string",
794
+ required: true
795
+ },
796
+ createdAt: {
797
+ type: "string",
798
+ required: true,
799
+ actualField: "created_at"
800
+ },
801
+ updatedAt: {
802
+ type: "string",
803
+ required: true,
804
+ actualField: "updated_at"
844
805
  }
845
- }
806
+ }),
807
+ mode: "replace"
846
808
  }
847
809
  },
848
810
  create: {
849
- bodyValidator: {
850
- schema: {
851
- type: "object",
852
- properties: {
853
- firstName: { type: "string" }
811
+ body: {
812
+ schema: createSchema({
813
+ firstName: {
814
+ type: "string"
854
815
  }
855
- }
816
+ }),
817
+ mode: "create"
856
818
  }
857
819
  }
858
- },
859
- fieldMeta: [
860
- { key: "id", repository: { column: "contact_id" } },
861
- { key: "firstName", repository: { column: "first_name" } },
862
- { key: "createdAt", repository: { column: "created_at" } },
863
- { key: "updatedAt", repository: { column: "updated_at" } }
864
- ]
820
+ }
865
821
  };
866
822
  const repository = createCrudResourceRuntime(resource, knex, {
867
823
  operations: {
@@ -928,7 +884,7 @@ test("update and delete keep canonical by-id behavior", async () => {
928
884
  });
929
885
  });
930
886
 
931
- test("update normalizes resource patch payloads using the existing record", async () => {
887
+ test("update normalizes resource patch payloads before persistence", async () => {
932
888
  const rows = [
933
889
  {
934
890
  contact_id: 11,
@@ -945,7 +901,7 @@ test("update normalizes resource patch payloads using the existing record", asyn
945
901
  firstName: " Tom "
946
902
  });
947
903
 
948
- assert.equal(state.updatePayloads[0].first_name, "Tom-Tony");
904
+ assert.equal(state.updatePayloads[0].first_name, "Tom");
949
905
  });
950
906
 
951
907
  test("update maps patch-only resource fields into the DB payload", async () => {
@@ -968,7 +924,7 @@ test("update maps patch-only resource fields into the DB payload", async () => {
968
924
  assert.equal(state.updatePayloads[0].last_seen_at, "2026-01-01T00:00:00.000Z");
969
925
  });
970
926
 
971
- test("resourceRuntime maps body normalization field errors for create and update", async () => {
927
+ test("resourceRuntime maps schema field errors for create and update", async () => {
972
928
  const rows = [
973
929
  {
974
930
  contact_id: 11,
@@ -981,18 +937,18 @@ test("resourceRuntime maps body normalization field errors for create and update
981
937
  const repository = createCrudResourceRuntime(createNormalizedWriteResourceFixture(), knex);
982
938
 
983
939
  await assert.rejects(
984
- () => repository.create({ firstName: "bad-create" }),
940
+ () => repository.create({ firstName: " " }),
985
941
  (error) => (
986
942
  error?.status === 400 &&
987
- error?.details?.fieldErrors?.firstName === "Invalid create value."
943
+ typeof error?.details?.fieldErrors?.firstName === "string"
988
944
  )
989
945
  );
990
946
 
991
947
  await assert.rejects(
992
- () => repository.updateById("11", { firstName: "bad-update" }),
948
+ () => repository.updateById("11", { firstName: " " }),
993
949
  (error) => (
994
950
  error?.status === 400 &&
995
- error?.details?.fieldErrors?.firstName === "Invalid update value."
951
+ typeof error?.details?.fieldErrors?.firstName === "string"
996
952
  )
997
953
  );
998
954
  });