@jskit-ai/crud-core 0.1.31 → 0.1.32

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,3 +1,4 @@
1
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
1
2
  import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
3
  import { normalizeObjectInput } from "@jskit-ai/kernel/shared/validators/inputNormalization";
3
4
  import { toSnakeCase } from "@jskit-ai/kernel/shared/support/stringCase";
@@ -10,6 +11,28 @@ import { isCrudRuntimeOutputOnlyFieldKey } from "../shared/crudFieldMetaSupport.
10
11
  const DEFAULT_LIST_LIMIT = 20;
11
12
  const MAX_LIST_LIMIT = 100;
12
13
 
14
+ function normalizeCrudListCursor(cursor = null, { allowEmpty = true } = {}) {
15
+ if (cursor === undefined || cursor === null) {
16
+ return allowEmpty === true ? 0 : null;
17
+ }
18
+
19
+ const normalizedCursor = typeof cursor === "string"
20
+ ? cursor.trim()
21
+ : cursor;
22
+ if (normalizedCursor === "" || normalizedCursor === 0 || normalizedCursor === "0") {
23
+ return allowEmpty === true ? 0 : null;
24
+ }
25
+
26
+ const numericCursor = Number(normalizedCursor);
27
+ if (!Number.isInteger(numericCursor) || numericCursor < 1) {
28
+ throw new AppError(400, "Invalid cursor.", {
29
+ code: "INVALID_CURSOR"
30
+ });
31
+ }
32
+
33
+ return numericCursor;
34
+ }
35
+
13
36
  function normalizeCrudListLimit(value, { fallback = DEFAULT_LIST_LIMIT, max = MAX_LIST_LIMIT } = {}) {
14
37
  const parsed = Number(value);
15
38
  if (!Number.isInteger(parsed) || parsed < 1) {
@@ -222,6 +245,7 @@ function applyCrudListQueryFilters(
222
245
  {
223
246
  idColumn = "id",
224
247
  cursor = 0,
248
+ applyCursor = true,
225
249
  q = "",
226
250
  searchColumns = [],
227
251
  parentFilters = {},
@@ -280,11 +304,12 @@ function applyCrudListQueryFilters(
280
304
  });
281
305
  }
282
306
 
283
- const numericCursor = Number(cursor);
284
- const normalizedCursor = Number.isInteger(numericCursor) && numericCursor > 0 ? numericCursor : 0;
285
307
  const normalizedIdColumn = String(idColumn || "").trim() || "id";
286
- if (normalizedCursor > 0) {
287
- nextQuery = nextQuery.where(normalizedIdColumn, ">", normalizedCursor);
308
+ if (applyCursor !== false) {
309
+ const normalizedCursor = normalizeCrudListCursor(cursor);
310
+ if (normalizedCursor > 0) {
311
+ nextQuery = nextQuery.where(normalizedIdColumn, ">", normalizedCursor);
312
+ }
288
313
  }
289
314
 
290
315
  return nextQuery;
@@ -319,6 +344,7 @@ export {
319
344
  DEFAULT_LIST_LIMIT,
320
345
  MAX_LIST_LIMIT,
321
346
  normalizeCrudListLimit,
347
+ normalizeCrudListCursor,
322
348
  requireCrudTableName,
323
349
  deriveRepositoryMappingFromResource,
324
350
  applyCrudListQueryFilters,
@@ -0,0 +1,140 @@
1
+ import { AppError, createValidationError } from "@jskit-ai/kernel/server/runtime/errors";
2
+ import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import { requireCrudNamespace } from "../shared/crudNamespaceSupport.js";
4
+ import { createCrudFieldAccessRuntime } from "./fieldAccess.js";
5
+
6
+ function createCrudServiceRuntime(resource = {}, { context = "crudService" } = {}) {
7
+ const namespace = requireCrudNamespace(resource?.resource, { context: `${context} resource.resource` });
8
+ const fieldAccessRuntime = createCrudFieldAccessRuntime(resource, { context });
9
+
10
+ return Object.freeze({
11
+ context,
12
+ namespace,
13
+ resource,
14
+ fieldAccessRuntime
15
+ });
16
+ }
17
+
18
+ function requireCrudServiceRepository(runtime = {}, repository = null) {
19
+ if (!repository) {
20
+ throw new Error(`${runtime?.context || "crudService"} requires repository.`);
21
+ }
22
+ return repository;
23
+ }
24
+
25
+ async function crudServiceListRecords(runtime, repository, fieldAccess = {}, query = {}, options = {}) {
26
+ const resolvedRepository = requireCrudServiceRepository(runtime, repository);
27
+ const result = await resolvedRepository.list(query, options);
28
+ return runtime.fieldAccessRuntime.filterReadableListResult(result, fieldAccess, {
29
+ action: "list",
30
+ query,
31
+ options,
32
+ context: options?.context
33
+ });
34
+ }
35
+
36
+ async function crudServiceGetRecord(runtime, repository, fieldAccess = {}, recordId, options = {}) {
37
+ const resolvedRepository = requireCrudServiceRepository(runtime, repository);
38
+ const record = await resolvedRepository.findById(recordId, options);
39
+ if (!record) {
40
+ throw new AppError(404, "Record not found.");
41
+ }
42
+
43
+ return runtime.fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
44
+ action: "view",
45
+ recordId,
46
+ options,
47
+ context: options?.context
48
+ });
49
+ }
50
+
51
+ async function crudServiceCreateRecord(runtime, repository, fieldAccess = {}, payload = {}, options = {}) {
52
+ const resolvedRepository = requireCrudServiceRepository(runtime, repository);
53
+ const writablePayload = await runtime.fieldAccessRuntime.enforceWritablePayload(payload, fieldAccess, {
54
+ action: "create",
55
+ payload,
56
+ options,
57
+ context: options?.context
58
+ });
59
+ const record = await resolvedRepository.create(writablePayload, options);
60
+ if (!record) {
61
+ throw new Error(`${runtime.namespace}Service could not load the created record.`);
62
+ }
63
+ return runtime.fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
64
+ action: "create",
65
+ options,
66
+ context: options?.context
67
+ });
68
+ }
69
+
70
+ async function crudServiceUpdateRecord(runtime, repository, fieldAccess = {}, recordId, payload = {}, options = {}) {
71
+ const resolvedRepository = requireCrudServiceRepository(runtime, repository);
72
+ const existingRecord = await resolvedRepository.findById(recordId, options);
73
+ if (!existingRecord) {
74
+ throw new AppError(404, "Record not found.");
75
+ }
76
+
77
+ const writablePayload = await runtime.fieldAccessRuntime.enforceWritablePayload(payload, fieldAccess, {
78
+ action: "update",
79
+ recordId,
80
+ payload,
81
+ options,
82
+ context: options?.context,
83
+ existingRecord
84
+ });
85
+
86
+ const patchBodyValidator = runtime.resource?.operations?.patch?.bodyValidator;
87
+ let normalizedPatch = writablePayload;
88
+ if (patchBodyValidator && typeof patchBodyValidator.normalize === "function") {
89
+ try {
90
+ normalizedPatch = await patchBodyValidator.normalize(writablePayload, {
91
+ phase: "crudPatch",
92
+ action: "update",
93
+ recordId,
94
+ existingRecord,
95
+ context: options?.context
96
+ });
97
+ } catch (error) {
98
+ const explicitFieldErrors = isRecord(error?.fieldErrors)
99
+ ? error.fieldErrors
100
+ : (
101
+ isRecord(error?.details?.fieldErrors)
102
+ ? error.details.fieldErrors
103
+ : null
104
+ );
105
+ if (explicitFieldErrors) {
106
+ throw createValidationError(explicitFieldErrors);
107
+ }
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ const record = await resolvedRepository.updateById(recordId, normalizedPatch, options);
113
+ if (!record) {
114
+ throw new AppError(404, "Record not found.");
115
+ }
116
+ return runtime.fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
117
+ action: "update",
118
+ recordId,
119
+ options,
120
+ context: options?.context
121
+ });
122
+ }
123
+
124
+ async function crudServiceDeleteRecord(runtime, repository, fieldAccess = {}, recordId, options = {}) {
125
+ const resolvedRepository = requireCrudServiceRepository(runtime, repository);
126
+ const deleted = await resolvedRepository.deleteById(recordId, options);
127
+ if (!deleted) {
128
+ throw new AppError(404, "Record not found.");
129
+ }
130
+ return deleted;
131
+ }
132
+
133
+ export {
134
+ createCrudServiceRuntime,
135
+ crudServiceListRecords,
136
+ crudServiceGetRecord,
137
+ crudServiceCreateRecord,
138
+ crudServiceUpdateRecord,
139
+ crudServiceDeleteRecord
140
+ };
@@ -22,13 +22,39 @@ function createListKnexDouble(
22
22
  let firstMode = false;
23
23
  const whereGroup = {
24
24
  where(...args) {
25
+ if (args.length === 1 && typeof args[0] === "function") {
26
+ calls.push(["innerWhereCallback"]);
27
+ args[0](whereGroup);
28
+ return whereGroup;
29
+ }
25
30
  calls.push(["where", ...args]);
26
31
  return whereGroup;
27
32
  },
28
33
  orWhere(...args) {
34
+ if (args.length === 1 && typeof args[0] === "function") {
35
+ calls.push(["innerOrWhereCallback"]);
36
+ args[0](whereGroup);
37
+ return whereGroup;
38
+ }
29
39
  calls.push(["orWhere", ...args]);
30
40
  return whereGroup;
31
41
  },
42
+ whereNull(...args) {
43
+ calls.push(["whereNull", ...args]);
44
+ return whereGroup;
45
+ },
46
+ orWhereNull(...args) {
47
+ calls.push(["orWhereNull", ...args]);
48
+ return whereGroup;
49
+ },
50
+ whereNotNull(...args) {
51
+ calls.push(["whereNotNull", ...args]);
52
+ return whereGroup;
53
+ },
54
+ orWhereNotNull(...args) {
55
+ calls.push(["orWhereNotNull", ...args]);
56
+ return whereGroup;
57
+ },
32
58
  whereRaw(...args) {
33
59
  calls.push(["whereRaw", ...args]);
34
60
  return whereGroup;
@@ -49,6 +75,31 @@ function createListKnexDouble(
49
75
  calls.push(["where", ...args]);
50
76
  return query;
51
77
  },
78
+ orWhere(...args) {
79
+ if (args.length === 1 && typeof args[0] === "function") {
80
+ calls.push(["orWhereCallback"]);
81
+ args[0](whereGroup);
82
+ return query;
83
+ }
84
+ calls.push(["orWhere", ...args]);
85
+ return query;
86
+ },
87
+ whereNull(...args) {
88
+ calls.push(["whereNull", ...args]);
89
+ return query;
90
+ },
91
+ orWhereNull(...args) {
92
+ calls.push(["orWhereNull", ...args]);
93
+ return query;
94
+ },
95
+ whereNotNull(...args) {
96
+ calls.push(["whereNotNull", ...args]);
97
+ return query;
98
+ },
99
+ orWhereNotNull(...args) {
100
+ calls.push(["orWhereNotNull", ...args]);
101
+ return query;
102
+ },
52
103
  whereRaw(...args) {
53
104
  calls.push(["whereRaw", ...args]);
54
105
  return query;
@@ -57,6 +108,10 @@ function createListKnexDouble(
57
108
  calls.push(["orderBy", ...args]);
58
109
  return query;
59
110
  },
111
+ orderByRaw(...args) {
112
+ calls.push(["orderByRaw", ...args]);
113
+ return query;
114
+ },
60
115
  clearOrder() {
61
116
  calls.push(["clearOrder"]);
62
117
  return query;
@@ -570,6 +625,226 @@ test("createCrudRepositoryFromResource allows list tuning through list config",
570
625
  assert.ok(calls.some((call) => call[0] === "limit" && call[1] === 3));
571
626
  });
572
627
 
628
+ test("createCrudRepositoryFromResource supports declarative ordered list pagination", async () => {
629
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture(), {
630
+ list: {
631
+ defaultLimit: 2,
632
+ orderBy: [
633
+ { column: "created_at", direction: "desc" }
634
+ ]
635
+ }
636
+ });
637
+ const rows = [
638
+ { contact_id: 9, first_name: "Tina", created_at: "2026-04-05T10:00:00.000Z" },
639
+ { contact_id: 7, first_name: "Tony", created_at: "2026-04-04T09:00:00.000Z" },
640
+ { contact_id: 6, first_name: "Tom", created_at: "2026-04-03T08:00:00.000Z" }
641
+ ];
642
+ const { knex, calls } = createListKnexDouble(rows);
643
+ const repository = createRepository(knex);
644
+
645
+ const result = await repository.list();
646
+
647
+ assert.deepEqual(result, {
648
+ items: [
649
+ { id: 9, firstName: "Tina" },
650
+ { id: 7, firstName: "Tony" }
651
+ ],
652
+ nextCursor: Buffer.from(
653
+ JSON.stringify({ values: ["2026-04-04T09:00:00.000Z", 7] }),
654
+ "utf8"
655
+ ).toString("base64url")
656
+ });
657
+ assert.ok(calls.some((call) => call[0] === "orderByRaw" && call[1] === "?? is null asc" && call[2]?.[0] === "created_at"));
658
+ assert.ok(calls.some((call) => call[0] === "orderBy" && call[1] === "created_at" && call[2] === "desc"));
659
+ assert.ok(calls.some((call) => call[0] === "orderBy" && call[1] === "contact_id" && call[2] === "desc"));
660
+ });
661
+
662
+ test("createCrudRepositoryFromResource applies ordered cursors using the configured sort tuple", async () => {
663
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture(), {
664
+ list: {
665
+ orderBy: [
666
+ { column: "created_at", direction: "desc" }
667
+ ]
668
+ }
669
+ });
670
+ const { knex, calls } = createListKnexDouble([
671
+ { contact_id: 6, first_name: "Tom", created_at: "2026-04-03T08:00:00.000Z" }
672
+ ]);
673
+ const repository = createRepository(knex);
674
+ const cursor = Buffer.from(
675
+ JSON.stringify({ values: ["2026-04-04T09:00:00.000Z", 7] }),
676
+ "utf8"
677
+ ).toString("base64url");
678
+
679
+ await repository.list({
680
+ cursor,
681
+ limit: 2
682
+ });
683
+
684
+ assert.ok(calls.some((call) => call[0] === "where" && call[1] === "created_at" && call[2] === "<" && call[3] === "2026-04-04T09:00:00.000Z"));
685
+ assert.ok(calls.some((call) => call[0] === "where" && call[1] === "created_at" && call[2] === "2026-04-04T09:00:00.000Z"));
686
+ assert.ok(calls.some((call) => call[0] === "where" && call[1] === "contact_id" && call[2] === "<" && call[3] === 7));
687
+ assert.ok(!calls.some((call) => call[0] === "where" && call[1] === "contact_id" && call[2] === ">" && call[3] === 7));
688
+ });
689
+
690
+ test("createCrudRepositoryFromResource rejects malformed ordered cursors", async () => {
691
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture(), {
692
+ list: {
693
+ orderBy: [
694
+ { column: "created_at", direction: "desc" }
695
+ ]
696
+ }
697
+ });
698
+ const { knex } = createListKnexDouble([]);
699
+ const repository = createRepository(knex);
700
+
701
+ await assert.rejects(
702
+ () => repository.list({
703
+ cursor: "not-a-real-cursor",
704
+ limit: 2
705
+ }),
706
+ /Invalid cursor/
707
+ );
708
+ });
709
+
710
+ test("createCrudRepositoryFromResource preserves Date cursor values for datetime sort columns", async () => {
711
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture(), {
712
+ list: {
713
+ defaultLimit: 2,
714
+ orderBy: [
715
+ { column: "created_at", direction: "desc" }
716
+ ]
717
+ }
718
+ });
719
+ const createdAt = new Date("2026-04-04T09:00:00.000Z");
720
+ const olderCreatedAt = new Date("2026-04-03T08:00:00.000Z");
721
+ const { knex, calls } = createListKnexDouble([
722
+ { contact_id: 9, first_name: "Tina", created_at: createdAt },
723
+ { contact_id: 7, first_name: "Tony", created_at: createdAt },
724
+ { contact_id: 6, first_name: "Tom", created_at: olderCreatedAt }
725
+ ]);
726
+ const repository = createRepository(knex);
727
+
728
+ const first = await repository.list();
729
+ const firstCallCount = calls.length;
730
+
731
+ await repository.list({
732
+ cursor: first.nextCursor,
733
+ limit: 2
734
+ });
735
+
736
+ const secondCallEntries = calls.slice(firstCallCount);
737
+ const afterCall = secondCallEntries.find((call) => (
738
+ call[0] === "where" &&
739
+ call[1] === "created_at" &&
740
+ call[2] === "<"
741
+ ));
742
+ const equalityCall = secondCallEntries.find((call) => (
743
+ call[0] === "where" &&
744
+ call[1] === "created_at" &&
745
+ call[2] instanceof Date
746
+ ));
747
+
748
+ assert.ok(first.nextCursor);
749
+ assert.ok(afterCall);
750
+ assert.ok(afterCall[3] instanceof Date);
751
+ assert.equal(afterCall[3].toISOString(), createdAt.toISOString());
752
+ assert.ok(equalityCall);
753
+ assert.equal(equalityCall[2].toISOString(), createdAt.toISOString());
754
+ });
755
+
756
+ test("createCrudRepositoryFromResource ordered cursors handle null primary sort values", async () => {
757
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture(), {
758
+ list: {
759
+ orderBy: [
760
+ { column: "created_at", direction: "desc" }
761
+ ]
762
+ }
763
+ });
764
+ const { knex, calls } = createListKnexDouble([
765
+ { contact_id: 6, first_name: "Tom", created_at: null }
766
+ ]);
767
+ const repository = createRepository(knex);
768
+ const cursor = Buffer.from(
769
+ JSON.stringify({ values: [null, 7] }),
770
+ "utf8"
771
+ ).toString("base64url");
772
+
773
+ await repository.list({
774
+ cursor,
775
+ limit: 2
776
+ });
777
+
778
+ assert.ok(calls.some((call) => call[0] === "whereNull" && call[1] === "created_at"));
779
+ assert.ok(calls.some((call) => call[0] === "where" && call[1] === "contact_id" && call[2] === "<" && call[3] === 7));
780
+ assert.ok(!calls.some((call) => call[0] === "whereNotNull" && call[1] === "created_at"));
781
+ });
782
+
783
+ test("createCrudRepositoryFromResource keeps ordered cursor prefix grouping for multi-column sorts", async () => {
784
+ const createRepository = createCrudRepositoryFromResource(createResourceFixture(), {
785
+ list: {
786
+ orderBy: [
787
+ { column: "created_at", direction: "desc" },
788
+ { column: "last_name", direction: "desc" }
789
+ ]
790
+ }
791
+ });
792
+ const { knex, calls } = createListKnexDouble([
793
+ {
794
+ contact_id: 6,
795
+ first_name: "Tom",
796
+ last_name: "Taylor",
797
+ created_at: "2026-04-03T08:00:00.000Z"
798
+ }
799
+ ]);
800
+ const repository = createRepository(knex);
801
+ const cursor = Buffer.from(
802
+ JSON.stringify({ values: ["2026-04-04T09:00:00.000Z", "Taylor", 7] }),
803
+ "utf8"
804
+ ).toString("base64url");
805
+
806
+ await repository.list({
807
+ cursor,
808
+ limit: 2
809
+ });
810
+
811
+ const createdAtEqualityIndex = calls.findIndex((call) => (
812
+ call[0] === "where" &&
813
+ call[1] === "created_at" &&
814
+ call[2] === "2026-04-04T09:00:00.000Z"
815
+ ));
816
+ const nestedGroupIndex = calls.findIndex((call, index) => (
817
+ index > createdAtEqualityIndex &&
818
+ call[0] === "innerWhereCallback"
819
+ ));
820
+ const lastNameAfterIndex = calls.findIndex((call, index) => (
821
+ index > nestedGroupIndex &&
822
+ call[0] === "where" &&
823
+ call[1] === "last_name" &&
824
+ call[2] === "<" &&
825
+ call[3] === "Taylor"
826
+ ));
827
+ const lastNameEqualityIndex = calls.findIndex((call, index) => (
828
+ index > lastNameAfterIndex &&
829
+ call[0] === "where" &&
830
+ call[1] === "last_name" &&
831
+ call[2] === "Taylor"
832
+ ));
833
+ const idAfterIndex = calls.findIndex((call, index) => (
834
+ index > lastNameEqualityIndex &&
835
+ call[0] === "where" &&
836
+ call[1] === "contact_id" &&
837
+ call[2] === "<" &&
838
+ call[3] === 7
839
+ ));
840
+
841
+ assert.ok(createdAtEqualityIndex >= 0);
842
+ assert.ok(nestedGroupIndex > createdAtEqualityIndex);
843
+ assert.ok(lastNameAfterIndex > nestedGroupIndex);
844
+ assert.ok(lastNameEqualityIndex > lastNameAfterIndex);
845
+ assert.ok(idAfterIndex > lastNameEqualityIndex);
846
+ });
847
+
573
848
  test("createCrudRepositoryFromResource exposes listByIds for lookup providers", async () => {
574
849
  const createRepository = createCrudRepositoryFromResource(createResourceFixture());
575
850
  const { knex, calls } = createListKnexDouble([
@@ -1487,6 +1762,37 @@ test("createCrudRepositoryFromResource create hooks keep write-key filtering and
1487
1762
  assert.ok(state.insertPayloads[0].updated_at);
1488
1763
  });
1489
1764
 
1765
+ test("createCrudRepositoryFromResource create hooks support finalizeInsertPayload after write-key filtering", async () => {
1766
+ const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
1767
+ const { knex, state } = createListKnexDouble([
1768
+ {
1769
+ contact_id: 11,
1770
+ first_name: "Tony",
1771
+ created_at: "2026-01-01 00:00:00",
1772
+ updated_at: "2026-01-01 00:00:00"
1773
+ }
1774
+ ], {
1775
+ insertResult: [11]
1776
+ });
1777
+ const repository = createRepository(knex);
1778
+
1779
+ await repository.create({
1780
+ firstName: "Tony",
1781
+ hiddenOwnerId: 44
1782
+ }, {}, {
1783
+ finalizeInsertPayload(insertPayload = {}, context = {}) {
1784
+ return {
1785
+ ...insertPayload,
1786
+ hidden_owner_id: context.payload?.hiddenOwnerId ?? null
1787
+ };
1788
+ }
1789
+ });
1790
+
1791
+ assert.equal(state.insertPayloads.length, 1);
1792
+ assert.equal(state.insertPayloads[0].first_name, "Tony");
1793
+ assert.equal(state.insertPayloads[0].hidden_owner_id, 44);
1794
+ });
1795
+
1490
1796
  test("createCrudRepositoryFromResource create hooks reject read-phase hook keys", async () => {
1491
1797
  const createRepository = createCrudRepositoryFromResource(createWritableHookResourceFixture());
1492
1798
  const { knex } = createListKnexDouble([
@@ -107,6 +107,98 @@ test("createCrudServiceFromResource delegates service methods and applies 404 se
107
107
  );
108
108
  });
109
109
 
110
+ test("createCrudServiceFromResource passes existing records to patch normalization", async () => {
111
+ const normalizeCalls = [];
112
+ const updateCalls = [];
113
+ const { createBaseService } = createCrudServiceFromResource({
114
+ resource: "contacts",
115
+ operations: {
116
+ patch: {
117
+ bodyValidator: {
118
+ normalize(payload = {}, context = {}) {
119
+ normalizeCalls.push({
120
+ payload,
121
+ existingRecord: context.existingRecord
122
+ });
123
+ return {
124
+ ...payload,
125
+ name: `${payload.name} normalized`
126
+ };
127
+ }
128
+ }
129
+ }
130
+ }
131
+ });
132
+
133
+ const service = createBaseService({
134
+ repository: createRepositoryDouble({
135
+ async findById(recordId) {
136
+ return recordId === 1
137
+ ? { id: 1, name: "Existing" }
138
+ : null;
139
+ },
140
+ async updateById(recordId, payload) {
141
+ updateCalls.push({ recordId, payload });
142
+ return { id: 1, ...payload };
143
+ }
144
+ })
145
+ });
146
+
147
+ const record = await service.updateRecord(1, { name: "B" }, {});
148
+
149
+ assert.deepEqual(normalizeCalls, [
150
+ {
151
+ payload: { name: "B" },
152
+ existingRecord: { id: 1, name: "Existing" }
153
+ }
154
+ ]);
155
+ assert.deepEqual(updateCalls, [
156
+ {
157
+ recordId: 1,
158
+ payload: { name: "B normalized" }
159
+ }
160
+ ]);
161
+ assert.deepEqual(record, { id: 1, name: "B normalized" });
162
+ });
163
+
164
+ test("createCrudServiceFromResource maps patch field errors to validation errors", async () => {
165
+ const { createBaseService } = createCrudServiceFromResource({
166
+ resource: "contacts",
167
+ operations: {
168
+ patch: {
169
+ bodyValidator: {
170
+ normalize() {
171
+ const error = new Error("Validation failed.");
172
+ error.details = {
173
+ fieldErrors: {
174
+ name: "Invalid."
175
+ }
176
+ };
177
+ throw error;
178
+ }
179
+ }
180
+ }
181
+ }
182
+ });
183
+
184
+ const service = createBaseService({
185
+ repository: createRepositoryDouble({
186
+ async findById() {
187
+ return { id: 1, name: "Existing" };
188
+ }
189
+ })
190
+ });
191
+
192
+ await assert.rejects(
193
+ () => service.updateRecord(1, { name: "B" }, {}),
194
+ (error) => (
195
+ error?.status === 400 &&
196
+ error?.message === "Validation failed." &&
197
+ error?.details?.fieldErrors?.name === "Invalid."
198
+ )
199
+ );
200
+ });
201
+
110
202
  test("createCrudServiceFromResource validates required inputs", async () => {
111
203
  assert.throws(
112
204
  () => createCrudServiceFromResource({}),