@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.
@@ -7,6 +7,7 @@ import { compileRouteValidator } from "@jskit-ai/kernel/_testable";
7
7
  import {
8
8
  listSearchQueryValidator,
9
9
  lookupIncludeQueryValidator,
10
+ createCrudCursorPaginationQueryValidator,
10
11
  createCrudParentFilterQueryValidator,
11
12
  resolveCrudParentFilterKeys
12
13
  } from "../src/server/listQueryValidators.js";
@@ -47,6 +48,29 @@ test("lookupIncludeQueryValidator keeps include optional when merged with pagina
47
48
  assert.deepEqual(compiled.schema.querystring.required || [], []);
48
49
  });
49
50
 
51
+ test("createCrudCursorPaginationQueryValidator keeps numeric cursor validation for unordered lists", () => {
52
+ const validator = createCrudCursorPaginationQueryValidator({});
53
+
54
+ assert.equal(validator, cursorPaginationQueryValidator);
55
+ });
56
+
57
+ test("createCrudCursorPaginationQueryValidator allows opaque cursor strings for ordered lists", () => {
58
+ const validator = createCrudCursorPaginationQueryValidator({
59
+ orderBy: [
60
+ {
61
+ column: "created_at",
62
+ direction: "desc"
63
+ }
64
+ ]
65
+ });
66
+
67
+ assert.notEqual(validator, cursorPaginationQueryValidator);
68
+ assert.deepEqual(validator.normalize({ cursor: " offset:3 ", limit: "25" }), {
69
+ cursor: "offset:3",
70
+ limit: 25
71
+ });
72
+ });
73
+
50
74
  test("resolveCrudParentFilterKeys returns lookup keys that exist in create schema", () => {
51
75
  const resource = {
52
76
  operations: {
@@ -129,6 +153,42 @@ test("createCrudParentFilterQueryValidator normalizes configured parent filters"
129
153
  });
130
154
  });
131
155
 
156
+ test("createCrudParentFilterQueryValidator keeps canonical field keys when fieldMeta declares parent route aliases", () => {
157
+ const validator = createCrudParentFilterQueryValidator({
158
+ operations: {
159
+ create: {
160
+ bodyValidator: {
161
+ schema: {
162
+ type: "object",
163
+ properties: {
164
+ staffContactId: { type: "integer" }
165
+ }
166
+ }
167
+ }
168
+ }
169
+ },
170
+ fieldMeta: [
171
+ {
172
+ key: "staffContactId",
173
+ parentRouteParamKey: "contactId",
174
+ relation: {
175
+ kind: "lookup",
176
+ apiPath: "/contacts",
177
+ valueKey: "id"
178
+ }
179
+ }
180
+ ]
181
+ });
182
+
183
+ assert.deepEqual(Object.keys(validator.schema.properties), ["staffContactId"]);
184
+ assert.deepEqual(validator.normalize({
185
+ staffContactId: " 42 ",
186
+ contactId: " 99 "
187
+ }), {
188
+ staffContactId: "42"
189
+ });
190
+ });
191
+
132
192
  test("createCrudParentFilterQueryValidator keeps parent filters optional when merged", () => {
133
193
  const parentValidator = createCrudParentFilterQueryValidator({
134
194
  operations: {
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import {
4
4
  DEFAULT_LIST_LIMIT,
5
5
  normalizeCrudListLimit,
6
+ normalizeCrudListCursor,
6
7
  requireCrudTableName,
7
8
  buildWritePayload,
8
9
  mapRecordRow,
@@ -16,12 +17,42 @@ function createQueryDouble() {
16
17
  const calls = [];
17
18
  const whereQuery = {
18
19
  where(...args) {
20
+ if (args.length === 1 && typeof args[0] === "function") {
21
+ calls.push(["innerWhereCallback"]);
22
+ args[0](whereQuery);
23
+ return whereQuery;
24
+ }
19
25
  calls.push(["innerWhere", ...args]);
20
26
  return whereQuery;
21
27
  },
22
28
  orWhere(...args) {
29
+ if (args.length === 1 && typeof args[0] === "function") {
30
+ calls.push(["innerOrWhereCallback"]);
31
+ args[0](whereQuery);
32
+ return whereQuery;
33
+ }
23
34
  calls.push(["innerOrWhere", ...args]);
24
35
  return whereQuery;
36
+ },
37
+ whereNull(...args) {
38
+ calls.push(["innerWhereNull", ...args]);
39
+ return whereQuery;
40
+ },
41
+ orWhereNull(...args) {
42
+ calls.push(["innerOrWhereNull", ...args]);
43
+ return whereQuery;
44
+ },
45
+ whereNotNull(...args) {
46
+ calls.push(["innerWhereNotNull", ...args]);
47
+ return whereQuery;
48
+ },
49
+ orWhereNotNull(...args) {
50
+ calls.push(["innerOrWhereNotNull", ...args]);
51
+ return whereQuery;
52
+ },
53
+ whereRaw(...args) {
54
+ calls.push(["innerWhereRaw", ...args]);
55
+ return whereQuery;
25
56
  }
26
57
  };
27
58
 
@@ -57,6 +88,15 @@ test("normalizeCrudListLimit enforces fallback and max", () => {
57
88
  assert.equal(normalizeCrudListLimit(200), 100);
58
89
  });
59
90
 
91
+ test("normalizeCrudListCursor rejects malformed id cursors", () => {
92
+ assert.equal(normalizeCrudListCursor("7"), 7);
93
+ assert.equal(normalizeCrudListCursor(""), 0);
94
+ assert.throws(
95
+ () => normalizeCrudListCursor("abc"),
96
+ /Invalid cursor/
97
+ );
98
+ });
99
+
60
100
  test("requireCrudTableName trims and rejects empty values", () => {
61
101
  assert.equal(requireCrudTableName(" crud_customers "), "crud_customers");
62
102
 
@@ -123,6 +163,25 @@ test("applyCrudListQueryFilters skips search and cursor when inputs are empty",
123
163
  assert.deepEqual(calls, []);
124
164
  });
125
165
 
166
+ test("applyCrudListQueryFilters can skip id cursor filtering for ordered lists", () => {
167
+ const { query, calls } = createQueryDouble();
168
+ const result = applyCrudListQueryFilters(query, {
169
+ cursor: "9",
170
+ applyCursor: false
171
+ });
172
+
173
+ assert.equal(result, query);
174
+ assert.deepEqual(calls, []);
175
+ });
176
+
177
+ test("applyCrudListQueryFilters rejects malformed id cursors", () => {
178
+ const { query } = createQueryDouble();
179
+ assert.throws(
180
+ () => applyCrudListQueryFilters(query, { cursor: "abc" }),
181
+ /Invalid cursor/
182
+ );
183
+ });
184
+
126
185
  test("applyCrudListQueryFilters applies parent FK filters from allowed columns", () => {
127
186
  const { query, calls } = createQueryDouble();
128
187
  const result = applyCrudListQueryFilters(query, {
@@ -0,0 +1,161 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ createCrudServiceRuntime,
5
+ crudServiceListRecords,
6
+ crudServiceGetRecord,
7
+ crudServiceCreateRecord,
8
+ crudServiceUpdateRecord,
9
+ crudServiceDeleteRecord
10
+ } from "../src/server/serviceMethods.js";
11
+
12
+ function createResourceWithOutputSchema(overrides = {}) {
13
+ return {
14
+ resource: "contacts",
15
+ operations: {
16
+ view: {
17
+ outputValidator: {
18
+ schema: {
19
+ type: "object",
20
+ properties: {
21
+ id: { type: "integer" },
22
+ name: { type: "string" }
23
+ },
24
+ required: ["id", "name"]
25
+ }
26
+ }
27
+ }
28
+ },
29
+ ...overrides
30
+ };
31
+ }
32
+
33
+ function createRepositoryDouble(overrides = {}) {
34
+ return {
35
+ async list(query) {
36
+ return { items: [query], nextCursor: null };
37
+ },
38
+ async findById(recordId) {
39
+ return recordId === 1 ? { id: 1, name: "Existing" } : null;
40
+ },
41
+ async create(payload) {
42
+ return { id: 2, ...payload };
43
+ },
44
+ async updateById(recordId, payload) {
45
+ if (recordId !== 1) {
46
+ return null;
47
+ }
48
+ return { id: 1, ...payload };
49
+ },
50
+ async deleteById(recordId) {
51
+ if (recordId !== 1) {
52
+ return null;
53
+ }
54
+ return { id: 1, deleted: true };
55
+ },
56
+ ...overrides
57
+ };
58
+ }
59
+
60
+ test("serviceMethods expose CRUD service behavior without the factory wrapper", async () => {
61
+ const runtime = createCrudServiceRuntime({
62
+ resource: "contacts"
63
+ });
64
+ const repository = createRepositoryDouble();
65
+
66
+ assert.deepEqual(
67
+ await crudServiceListRecords(runtime, repository, {}, { limit: 2 }, {}),
68
+ { items: [{ limit: 2 }], nextCursor: null }
69
+ );
70
+ assert.deepEqual(await crudServiceGetRecord(runtime, repository, {}, 1, {}), { id: 1, name: "Existing" });
71
+ assert.deepEqual(await crudServiceCreateRecord(runtime, repository, {}, { name: "A" }, {}), { id: 2, name: "A" });
72
+ assert.deepEqual(await crudServiceUpdateRecord(runtime, repository, {}, 1, { name: "B" }, {}), { id: 1, name: "B" });
73
+ assert.deepEqual(await crudServiceDeleteRecord(runtime, repository, {}, 1, {}), { id: 1, deleted: true });
74
+ });
75
+
76
+ test("serviceMethods apply patch normalization using the existing record and map field errors", async () => {
77
+ const runtime = createCrudServiceRuntime({
78
+ resource: "contacts",
79
+ operations: {
80
+ patch: {
81
+ bodyValidator: {
82
+ normalize(payload = {}, context = {}) {
83
+ if (payload.name === "bad") {
84
+ const error = new Error("Validation failed.");
85
+ error.details = {
86
+ fieldErrors: {
87
+ name: "Invalid."
88
+ }
89
+ };
90
+ throw error;
91
+ }
92
+ return {
93
+ ...payload,
94
+ name: `${payload.name} normalized`,
95
+ existingName: context.existingRecord?.name || ""
96
+ };
97
+ }
98
+ }
99
+ },
100
+ view: createResourceWithOutputSchema().operations.view
101
+ }
102
+ });
103
+ const updateCalls = [];
104
+ const repository = createRepositoryDouble({
105
+ async updateById(recordId, payload) {
106
+ updateCalls.push({ recordId, payload });
107
+ return { id: recordId, ...payload };
108
+ }
109
+ });
110
+
111
+ const updated = await crudServiceUpdateRecord(runtime, repository, {}, 1, { name: "good" }, {});
112
+
113
+ assert.deepEqual(updateCalls, [
114
+ {
115
+ recordId: 1,
116
+ payload: {
117
+ name: "good normalized",
118
+ existingName: "Existing"
119
+ }
120
+ }
121
+ ]);
122
+ assert.deepEqual(updated, {
123
+ id: 1,
124
+ name: "good normalized",
125
+ existingName: "Existing"
126
+ });
127
+
128
+ await assert.rejects(
129
+ () => crudServiceUpdateRecord(runtime, repository, {}, 1, { name: "bad" }, {}),
130
+ (error) => (
131
+ error?.status === 400 &&
132
+ error?.details?.fieldErrors?.name === "Invalid."
133
+ )
134
+ );
135
+ });
136
+
137
+ test("serviceMethods enforce writable field access policies and allow readable filtering", async () => {
138
+ const runtime = createCrudServiceRuntime(createResourceWithOutputSchema());
139
+ const createCalls = [];
140
+ const repository = createRepositoryDouble({
141
+ async create(payload) {
142
+ createCalls.push(payload);
143
+ return { id: 2, ...payload };
144
+ }
145
+ });
146
+ const fieldAccess = {
147
+ readable: () => ["id", "name"],
148
+ writable: () => ["name"],
149
+ writeMode: "strip"
150
+ };
151
+
152
+ const created = await crudServiceCreateRecord(runtime, repository, fieldAccess, {
153
+ name: "Allowed",
154
+ secret: "Blocked"
155
+ }, {});
156
+ const viewed = await crudServiceGetRecord(runtime, repository, fieldAccess, 1, {});
157
+
158
+ assert.deepEqual(createCalls, [{ name: "Allowed" }]);
159
+ assert.deepEqual(created, { id: 2, name: "Allowed" });
160
+ assert.deepEqual(viewed, { id: 1, name: "Existing" });
161
+ });