@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.
- package/package.descriptor.mjs +2 -2
- package/package.json +7 -6
- package/src/server/createCrudServiceFromResource.js +14 -60
- package/src/server/listQueryValidators.js +57 -9
- package/src/server/repositoryMethods.js +333 -9
- package/src/server/repositorySupport.js +30 -4
- package/src/server/serviceMethods.js +140 -0
- package/test/createCrudRepositoryFromResource.test.js +306 -0
- package/test/createCrudServiceFromResource.test.js +92 -0
- package/test/listQueryValidators.test.js +60 -0
- package/test/repositorySupport.test.js +59 -0
- package/test/serviceMethods.test.js +161 -0
|
@@ -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 (
|
|
287
|
-
|
|
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({}),
|