@jskit-ai/crud-server-generator 0.1.26
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 +218 -0
- package/package.json +22 -0
- package/src/server/CrudServiceProvider.js +11 -0
- package/src/server/actionIds.js +22 -0
- package/src/server/actions.js +152 -0
- package/src/server/buildTemplateContext.js +871 -0
- package/src/server/crudModuleConfig.js +1 -0
- package/src/server/registerRoutes.js +234 -0
- package/src/server/repository.js +162 -0
- package/src/server/service.js +96 -0
- package/src/shared/crud/crudResource.js +191 -0
- package/src/shared/index.js +1 -0
- package/templates/migrations/crud_initial.cjs +17 -0
- package/templates/src/local-package/package.descriptor.mjs +67 -0
- package/templates/src/local-package/package.json +10 -0
- package/templates/src/local-package/server/CrudServiceProvider.js +84 -0
- package/templates/src/local-package/server/actionIds.js +9 -0
- package/templates/src/local-package/server/actions.js +151 -0
- package/templates/src/local-package/server/registerRoutes.js +197 -0
- package/templates/src/local-package/server/repository.js +161 -0
- package/templates/src/local-package/server/service.js +96 -0
- package/templates/src/local-package/shared/crudResource.js +109 -0
- package/templates/src/local-package/shared/index.js +3 -0
- package/test/buildTemplateContext.test.js +256 -0
- package/test/crudModuleConfig.test.js +225 -0
- package/test/crudResource.test.js +41 -0
- package/test/crudServerGuards.test.js +61 -0
- package/test/crudService.test.js +83 -0
- package/test/routeInputContracts.test.js +215 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "@jskit-ai/crud-core/server/crudModuleConfig";
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { withStandardErrorResponses } from "@jskit-ai/http-runtime/shared/validators/errorResponses";
|
|
2
|
+
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
|
|
3
|
+
import {
|
|
4
|
+
cursorPaginationQueryValidator,
|
|
5
|
+
recordIdParamsValidator
|
|
6
|
+
} from "@jskit-ai/kernel/shared/validators";
|
|
7
|
+
import { routeParamsValidator } from "@jskit-ai/users-core/server/validators/routeParamsValidator";
|
|
8
|
+
import { normalizeScopedRouteVisibility } from "@jskit-ai/users-core/shared/support/usersVisibility";
|
|
9
|
+
import { buildWorkspaceInputFromRouteParams } from "@jskit-ai/users-core/server/support/workspaceRouteInput";
|
|
10
|
+
import { resolveApiBasePath } from "@jskit-ai/users-core/shared/support/usersApiPaths";
|
|
11
|
+
import { crudResource } from "../shared/crud/crudResource.js";
|
|
12
|
+
|
|
13
|
+
function joinRoutePath(basePath = "", suffix = "") {
|
|
14
|
+
const base = String(basePath || "").trim().replace(/\/+$/g, "");
|
|
15
|
+
const end = String(suffix || "").trim();
|
|
16
|
+
if (!end) {
|
|
17
|
+
return base;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return `${base}/${end.replace(/^\/+/, "")}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function requireRouteRelativePath(routeRelativePath) {
|
|
24
|
+
const routePath = String(routeRelativePath || "").trim();
|
|
25
|
+
if (!routePath) {
|
|
26
|
+
throw new TypeError("registerRoutes requires routeRelativePath.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return routePath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function requireActionIds(actionIds) {
|
|
33
|
+
const source = actionIds && typeof actionIds === "object" && !Array.isArray(actionIds) ? actionIds : null;
|
|
34
|
+
if (!source) {
|
|
35
|
+
throw new TypeError("registerRoutes requires actionIds.");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const requiredKeys = ["list", "view", "create", "update", "delete"];
|
|
39
|
+
const normalized = {};
|
|
40
|
+
for (const key of requiredKeys) {
|
|
41
|
+
const value = String(source[key] || "").trim();
|
|
42
|
+
if (!value) {
|
|
43
|
+
throw new TypeError(`registerRoutes requires actionIds.${key}.`);
|
|
44
|
+
}
|
|
45
|
+
normalized[key] = value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return Object.freeze(normalized);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function registerRoutes(
|
|
52
|
+
app,
|
|
53
|
+
{
|
|
54
|
+
routeRelativePath,
|
|
55
|
+
routeOwnershipFilter = "public",
|
|
56
|
+
routeSurface = "",
|
|
57
|
+
routeSurfaceRequiresWorkspace = false,
|
|
58
|
+
actionIds
|
|
59
|
+
} = {}
|
|
60
|
+
) {
|
|
61
|
+
if (!app || typeof app.make !== "function") {
|
|
62
|
+
throw new Error("registerRoutes requires application make().");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const router = app.make("jskit.http.router");
|
|
66
|
+
const relativePath = requireRouteRelativePath(routeRelativePath);
|
|
67
|
+
const routeBase = resolveApiBasePath({
|
|
68
|
+
surfaceRequiresWorkspace: routeSurfaceRequiresWorkspace === true,
|
|
69
|
+
relativePath
|
|
70
|
+
});
|
|
71
|
+
const routeVisibility = normalizeScopedRouteVisibility(routeOwnershipFilter, {
|
|
72
|
+
fallback: "public"
|
|
73
|
+
});
|
|
74
|
+
const surface = normalizeSurfaceId(routeSurface);
|
|
75
|
+
const resolvedActionIds = requireActionIds(actionIds);
|
|
76
|
+
|
|
77
|
+
router.register(
|
|
78
|
+
"GET",
|
|
79
|
+
routeBase,
|
|
80
|
+
{
|
|
81
|
+
auth: "required",
|
|
82
|
+
surface,
|
|
83
|
+
visibility: routeVisibility,
|
|
84
|
+
meta: {
|
|
85
|
+
tags: ["crud"],
|
|
86
|
+
summary: "List records."
|
|
87
|
+
},
|
|
88
|
+
paramsValidator: routeParamsValidator,
|
|
89
|
+
queryValidator: cursorPaginationQueryValidator,
|
|
90
|
+
responseValidators: withStandardErrorResponses({
|
|
91
|
+
200: crudResource.operations.list.outputValidator
|
|
92
|
+
})
|
|
93
|
+
},
|
|
94
|
+
async function (request, reply) {
|
|
95
|
+
const listInput = {
|
|
96
|
+
...buildWorkspaceInputFromRouteParams(request.input.params)
|
|
97
|
+
};
|
|
98
|
+
if (request.input.query.cursor != null) {
|
|
99
|
+
listInput.cursor = request.input.query.cursor;
|
|
100
|
+
}
|
|
101
|
+
if (request.input.query.limit != null) {
|
|
102
|
+
listInput.limit = request.input.query.limit;
|
|
103
|
+
}
|
|
104
|
+
const response = await request.executeAction({
|
|
105
|
+
actionId: resolvedActionIds.list,
|
|
106
|
+
input: listInput
|
|
107
|
+
});
|
|
108
|
+
reply.code(200).send(response);
|
|
109
|
+
}
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
router.register(
|
|
113
|
+
"GET",
|
|
114
|
+
joinRoutePath(routeBase, ":recordId"),
|
|
115
|
+
{
|
|
116
|
+
auth: "required",
|
|
117
|
+
surface,
|
|
118
|
+
visibility: routeVisibility,
|
|
119
|
+
meta: {
|
|
120
|
+
tags: ["crud"],
|
|
121
|
+
summary: "View a record."
|
|
122
|
+
},
|
|
123
|
+
paramsValidator: [routeParamsValidator, recordIdParamsValidator],
|
|
124
|
+
responseValidators: withStandardErrorResponses({
|
|
125
|
+
200: crudResource.operations.view.outputValidator
|
|
126
|
+
})
|
|
127
|
+
},
|
|
128
|
+
async function (request, reply) {
|
|
129
|
+
const response = await request.executeAction({
|
|
130
|
+
actionId: resolvedActionIds.view,
|
|
131
|
+
input: {
|
|
132
|
+
...buildWorkspaceInputFromRouteParams(request.input.params),
|
|
133
|
+
recordId: request.input.params.recordId
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
reply.code(200).send(response);
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
router.register(
|
|
141
|
+
"POST",
|
|
142
|
+
routeBase,
|
|
143
|
+
{
|
|
144
|
+
auth: "required",
|
|
145
|
+
surface,
|
|
146
|
+
visibility: routeVisibility,
|
|
147
|
+
meta: {
|
|
148
|
+
tags: ["crud"],
|
|
149
|
+
summary: "Create a record."
|
|
150
|
+
},
|
|
151
|
+
paramsValidator: routeParamsValidator,
|
|
152
|
+
bodyValidator: crudResource.operations.create.bodyValidator,
|
|
153
|
+
responseValidators: withStandardErrorResponses(
|
|
154
|
+
{
|
|
155
|
+
201: crudResource.operations.create.outputValidator
|
|
156
|
+
},
|
|
157
|
+
{ includeValidation400: true }
|
|
158
|
+
)
|
|
159
|
+
},
|
|
160
|
+
async function (request, reply) {
|
|
161
|
+
const response = await request.executeAction({
|
|
162
|
+
actionId: resolvedActionIds.create,
|
|
163
|
+
input: {
|
|
164
|
+
...buildWorkspaceInputFromRouteParams(request.input.params),
|
|
165
|
+
payload: request.input.body
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
reply.code(201).send(response);
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
router.register(
|
|
173
|
+
"PATCH",
|
|
174
|
+
joinRoutePath(routeBase, ":recordId"),
|
|
175
|
+
{
|
|
176
|
+
auth: "required",
|
|
177
|
+
surface,
|
|
178
|
+
visibility: routeVisibility,
|
|
179
|
+
meta: {
|
|
180
|
+
tags: ["crud"],
|
|
181
|
+
summary: "Update a record."
|
|
182
|
+
},
|
|
183
|
+
paramsValidator: [routeParamsValidator, recordIdParamsValidator],
|
|
184
|
+
bodyValidator: crudResource.operations.patch.bodyValidator,
|
|
185
|
+
responseValidators: withStandardErrorResponses(
|
|
186
|
+
{
|
|
187
|
+
200: crudResource.operations.patch.outputValidator
|
|
188
|
+
},
|
|
189
|
+
{ includeValidation400: true }
|
|
190
|
+
)
|
|
191
|
+
},
|
|
192
|
+
async function (request, reply) {
|
|
193
|
+
const response = await request.executeAction({
|
|
194
|
+
actionId: resolvedActionIds.update,
|
|
195
|
+
input: {
|
|
196
|
+
...buildWorkspaceInputFromRouteParams(request.input.params),
|
|
197
|
+
recordId: request.input.params.recordId,
|
|
198
|
+
patch: request.input.body
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
reply.code(200).send(response);
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
router.register(
|
|
206
|
+
"DELETE",
|
|
207
|
+
joinRoutePath(routeBase, ":recordId"),
|
|
208
|
+
{
|
|
209
|
+
auth: "required",
|
|
210
|
+
surface,
|
|
211
|
+
visibility: routeVisibility,
|
|
212
|
+
meta: {
|
|
213
|
+
tags: ["crud"],
|
|
214
|
+
summary: "Delete a record."
|
|
215
|
+
},
|
|
216
|
+
paramsValidator: [routeParamsValidator, recordIdParamsValidator],
|
|
217
|
+
responseValidators: withStandardErrorResponses({
|
|
218
|
+
200: crudResource.operations.delete.outputValidator
|
|
219
|
+
})
|
|
220
|
+
},
|
|
221
|
+
async function (request, reply) {
|
|
222
|
+
const response = await request.executeAction({
|
|
223
|
+
actionId: resolvedActionIds.delete,
|
|
224
|
+
input: {
|
|
225
|
+
...buildWorkspaceInputFromRouteParams(request.input.params),
|
|
226
|
+
recordId: request.input.params.recordId
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
reply.code(200).send(response);
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export { registerRoutes };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { toInsertDateTime } from "@jskit-ai/database-runtime/shared";
|
|
2
|
+
import { applyVisibility, applyVisibilityOwners } from "@jskit-ai/database-runtime/shared/visibility";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_LIST_LIMIT,
|
|
5
|
+
normalizeCrudListLimit,
|
|
6
|
+
requireCrudTableName
|
|
7
|
+
} from "@jskit-ai/crud-core/server/repositorySupport";
|
|
8
|
+
import { normalizeObjectInput } from "@jskit-ai/kernel/shared/validators/inputNormalization";
|
|
9
|
+
import { pickOwnProperties } from "@jskit-ai/kernel/shared/support";
|
|
10
|
+
|
|
11
|
+
function mapRecordRow(row) {
|
|
12
|
+
if (!row) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
id: row.id,
|
|
18
|
+
textField: row.text_field,
|
|
19
|
+
dateField: row.date_field,
|
|
20
|
+
numberField: row.number_field,
|
|
21
|
+
createdAt: row.created_at,
|
|
22
|
+
updatedAt: row.updated_at
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createRepository(knex, { tableName } = {}) {
|
|
27
|
+
if (typeof knex !== "function") {
|
|
28
|
+
throw new TypeError("crudRepository requires knex.");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const resolvedTableName = requireCrudTableName(tableName);
|
|
32
|
+
|
|
33
|
+
async function list({ cursor = 0, limit = DEFAULT_LIST_LIMIT } = {}, options = {}) {
|
|
34
|
+
const client = options?.trx || knex;
|
|
35
|
+
const normalizedCursor = Number.isInteger(Number(cursor)) && Number(cursor) > 0 ? Number(cursor) : 0;
|
|
36
|
+
const normalizedLimit = normalizeCrudListLimit(limit);
|
|
37
|
+
const visible = (queryBuilder) => applyVisibility(queryBuilder, options.visibilityContext);
|
|
38
|
+
|
|
39
|
+
let query = client(resolvedTableName)
|
|
40
|
+
.select("id", "text_field", "date_field", "number_field", "created_at", "updated_at")
|
|
41
|
+
.where(visible)
|
|
42
|
+
.orderBy("id", "asc")
|
|
43
|
+
.limit(normalizedLimit + 1);
|
|
44
|
+
|
|
45
|
+
if (normalizedCursor > 0) {
|
|
46
|
+
query = query.where("id", ">", normalizedCursor);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const rows = await query;
|
|
50
|
+
const hasMore = rows.length > normalizedLimit;
|
|
51
|
+
const pageRows = hasMore ? rows.slice(0, normalizedLimit) : rows;
|
|
52
|
+
const items = pageRows.map((row) => mapRecordRow(row));
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
items,
|
|
56
|
+
nextCursor: hasMore && items.length > 0 ? String(items[items.length - 1].id) : null
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function findById(recordId, options = {}) {
|
|
61
|
+
const client = options?.trx || knex;
|
|
62
|
+
const visible = (queryBuilder) => applyVisibility(queryBuilder, options.visibilityContext);
|
|
63
|
+
const row = await client(resolvedTableName)
|
|
64
|
+
.select("id", "text_field", "date_field", "number_field", "created_at", "updated_at")
|
|
65
|
+
.where(visible)
|
|
66
|
+
.where({ id: Number(recordId) })
|
|
67
|
+
.first();
|
|
68
|
+
|
|
69
|
+
return mapRecordRow(row);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function create(payload = {}, options = {}) {
|
|
73
|
+
const client = options?.trx || knex;
|
|
74
|
+
const source = normalizeObjectInput(payload);
|
|
75
|
+
const timestamp = toInsertDateTime();
|
|
76
|
+
const insertPayload = applyVisibilityOwners(
|
|
77
|
+
{
|
|
78
|
+
text_field: source.textField,
|
|
79
|
+
date_field: source.dateField,
|
|
80
|
+
number_field: source.numberField,
|
|
81
|
+
created_at: timestamp,
|
|
82
|
+
updated_at: timestamp
|
|
83
|
+
},
|
|
84
|
+
options.visibilityContext
|
|
85
|
+
);
|
|
86
|
+
const [recordId] = await client(resolvedTableName).insert({
|
|
87
|
+
...insertPayload
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return findById(recordId, {
|
|
91
|
+
...options,
|
|
92
|
+
trx: client
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function updateById(recordId, patch = {}, options = {}) {
|
|
97
|
+
const client = options?.trx || knex;
|
|
98
|
+
const source = normalizeObjectInput(patch);
|
|
99
|
+
const dbPatch = {};
|
|
100
|
+
const patchSource = pickOwnProperties(source, ["textField", "dateField", "numberField"]);
|
|
101
|
+
if (Object.hasOwn(patchSource, "textField")) {
|
|
102
|
+
dbPatch.text_field = patchSource.textField;
|
|
103
|
+
}
|
|
104
|
+
if (Object.hasOwn(patchSource, "dateField")) {
|
|
105
|
+
dbPatch.date_field = patchSource.dateField;
|
|
106
|
+
}
|
|
107
|
+
if (Object.hasOwn(patchSource, "numberField")) {
|
|
108
|
+
dbPatch.number_field = patchSource.numberField;
|
|
109
|
+
}
|
|
110
|
+
const visible = (queryBuilder) => applyVisibility(queryBuilder, options.visibilityContext);
|
|
111
|
+
|
|
112
|
+
if (Object.keys(dbPatch).length === 0) {
|
|
113
|
+
return findById(recordId, {
|
|
114
|
+
...options,
|
|
115
|
+
trx: client
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await client(resolvedTableName)
|
|
120
|
+
.where(visible)
|
|
121
|
+
.where({ id: Number(recordId) })
|
|
122
|
+
.update({
|
|
123
|
+
...dbPatch,
|
|
124
|
+
updated_at: toInsertDateTime()
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return findById(recordId, {
|
|
128
|
+
...options,
|
|
129
|
+
trx: client
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function deleteById(recordId, options = {}) {
|
|
134
|
+
const client = options?.trx || knex;
|
|
135
|
+
const visible = (queryBuilder) => applyVisibility(queryBuilder, options.visibilityContext);
|
|
136
|
+
const existing = await findById(recordId, {
|
|
137
|
+
...options,
|
|
138
|
+
trx: client
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (!existing) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await client(resolvedTableName).where(visible).where({ id: Number(recordId) }).delete();
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
id: existing.id,
|
|
149
|
+
deleted: true
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return Object.freeze({
|
|
154
|
+
list,
|
|
155
|
+
findById,
|
|
156
|
+
create,
|
|
157
|
+
updateById,
|
|
158
|
+
deleteById
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export { createRepository };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
|
+
|
|
3
|
+
const serviceEvents = Object.freeze({
|
|
4
|
+
createRecord: Object.freeze([
|
|
5
|
+
Object.freeze({
|
|
6
|
+
type: "entity.changed",
|
|
7
|
+
source: "crud",
|
|
8
|
+
entity: "record",
|
|
9
|
+
operation: "created",
|
|
10
|
+
entityId: ({ result }) => result?.id,
|
|
11
|
+
realtime: Object.freeze({
|
|
12
|
+
event: "crud.record.changed",
|
|
13
|
+
audience: "event_scope"
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
]),
|
|
17
|
+
updateRecord: Object.freeze([
|
|
18
|
+
Object.freeze({
|
|
19
|
+
type: "entity.changed",
|
|
20
|
+
source: "crud",
|
|
21
|
+
entity: "record",
|
|
22
|
+
operation: "updated",
|
|
23
|
+
entityId: ({ result }) => result?.id,
|
|
24
|
+
realtime: Object.freeze({
|
|
25
|
+
event: "crud.record.changed",
|
|
26
|
+
audience: "event_scope"
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
]),
|
|
30
|
+
deleteRecord: Object.freeze([
|
|
31
|
+
Object.freeze({
|
|
32
|
+
type: "entity.changed",
|
|
33
|
+
source: "crud",
|
|
34
|
+
entity: "record",
|
|
35
|
+
operation: "deleted",
|
|
36
|
+
entityId: ({ result }) => result?.id,
|
|
37
|
+
realtime: Object.freeze({
|
|
38
|
+
event: "crud.record.changed",
|
|
39
|
+
audience: "event_scope"
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
])
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function createService({ crudRepository } = {}) {
|
|
46
|
+
if (!crudRepository) {
|
|
47
|
+
throw new Error("crudService requires crudRepository.");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function listRecords(query = {}, options = {}) {
|
|
51
|
+
return crudRepository.list(query, options);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function getRecord(recordId, options = {}) {
|
|
55
|
+
const record = await crudRepository.findById(recordId, options);
|
|
56
|
+
if (!record) {
|
|
57
|
+
throw new AppError(404, "Record not found.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return record;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function createRecord(payload = {}, options = {}) {
|
|
64
|
+
const record = await crudRepository.create(payload, options);
|
|
65
|
+
if (!record) {
|
|
66
|
+
throw new Error("crudService could not load the created record.");
|
|
67
|
+
}
|
|
68
|
+
return record;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function updateRecord(recordId, payload = {}, options = {}) {
|
|
72
|
+
const record = await crudRepository.updateById(recordId, payload, options);
|
|
73
|
+
if (!record) {
|
|
74
|
+
throw new AppError(404, "Record not found.");
|
|
75
|
+
}
|
|
76
|
+
return record;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function deleteRecord(recordId, options = {}) {
|
|
80
|
+
const deleted = await crudRepository.deleteById(recordId, options);
|
|
81
|
+
if (!deleted) {
|
|
82
|
+
throw new AppError(404, "Record not found.");
|
|
83
|
+
}
|
|
84
|
+
return deleted;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
listRecords,
|
|
89
|
+
getRecord,
|
|
90
|
+
createRecord,
|
|
91
|
+
updateRecord,
|
|
92
|
+
deleteRecord
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { createService, serviceEvents };
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import {
|
|
3
|
+
toIsoString,
|
|
4
|
+
toDatabaseDateTimeUtc
|
|
5
|
+
} from "@jskit-ai/database-runtime/shared";
|
|
6
|
+
import {
|
|
7
|
+
normalizeObjectInput,
|
|
8
|
+
createCursorListValidator
|
|
9
|
+
} from "@jskit-ai/kernel/shared/validators";
|
|
10
|
+
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
11
|
+
|
|
12
|
+
function normalizeNumberField(value, { fieldLabel = "Number field" } = {}) {
|
|
13
|
+
const normalized = Number(value);
|
|
14
|
+
if (!Number.isFinite(normalized)) {
|
|
15
|
+
throw new TypeError(`${fieldLabel} must be a valid number.`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return normalized;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeDateTimeField(value, { fieldLabel = "Date field" } = {}) {
|
|
22
|
+
try {
|
|
23
|
+
return toIsoString(value);
|
|
24
|
+
} catch {
|
|
25
|
+
throw new TypeError(`${fieldLabel} must be a valid date/time.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeDatabaseDateTimeField(value, { fieldLabel = "Date field" } = {}) {
|
|
30
|
+
try {
|
|
31
|
+
return toDatabaseDateTimeUtc(value);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new TypeError(`${fieldLabel} must be a valid date/time.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeRecordInput(payload = {}) {
|
|
38
|
+
const source = normalizeObjectInput(payload);
|
|
39
|
+
const normalized = {};
|
|
40
|
+
|
|
41
|
+
if (Object.hasOwn(source, "textField")) {
|
|
42
|
+
normalized.textField = normalizeText(source.textField);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (Object.hasOwn(source, "dateField")) {
|
|
46
|
+
normalized.dateField = normalizeDatabaseDateTimeField(source.dateField, {
|
|
47
|
+
fieldLabel: "Date field"
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (Object.hasOwn(source, "numberField")) {
|
|
52
|
+
normalized.numberField = normalizeNumberField(source.numberField, {
|
|
53
|
+
fieldLabel: "Number field"
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return normalized;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeRecordOutput(payload = {}) {
|
|
61
|
+
const source = normalizeObjectInput(payload);
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
id: Number(source.id),
|
|
65
|
+
textField: normalizeText(source.textField),
|
|
66
|
+
dateField: normalizeDateTimeField(source.dateField, {
|
|
67
|
+
fieldLabel: "Date field"
|
|
68
|
+
}),
|
|
69
|
+
numberField: normalizeNumberField(source.numberField, {
|
|
70
|
+
fieldLabel: "Number field"
|
|
71
|
+
}),
|
|
72
|
+
createdAt: normalizeDateTimeField(source.createdAt, {
|
|
73
|
+
fieldLabel: "Created at"
|
|
74
|
+
}),
|
|
75
|
+
updatedAt: normalizeDateTimeField(source.updatedAt, {
|
|
76
|
+
fieldLabel: "Updated at"
|
|
77
|
+
})
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const recordOutputSchema = Type.Object(
|
|
82
|
+
{
|
|
83
|
+
id: Type.Integer({ minimum: 1 }),
|
|
84
|
+
textField: Type.String({ minLength: 1, maxLength: 160 }),
|
|
85
|
+
dateField: Type.String({ minLength: 1 }),
|
|
86
|
+
numberField: Type.Number(),
|
|
87
|
+
createdAt: Type.String({ minLength: 1 }),
|
|
88
|
+
updatedAt: Type.String({ minLength: 1 })
|
|
89
|
+
},
|
|
90
|
+
{ additionalProperties: false }
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const recordBodySchema = Type.Object(
|
|
94
|
+
{
|
|
95
|
+
textField: Type.String({
|
|
96
|
+
minLength: 1,
|
|
97
|
+
maxLength: 160,
|
|
98
|
+
messages: {
|
|
99
|
+
required: "Text field is required.",
|
|
100
|
+
minLength: "Text field is required.",
|
|
101
|
+
maxLength: "Text field must be at most 160 characters.",
|
|
102
|
+
default: "Text field is required."
|
|
103
|
+
}
|
|
104
|
+
}),
|
|
105
|
+
dateField: Type.String({
|
|
106
|
+
minLength: 1,
|
|
107
|
+
messages: {
|
|
108
|
+
required: "Date field is required.",
|
|
109
|
+
minLength: "Date field is required.",
|
|
110
|
+
default: "Date field is required."
|
|
111
|
+
}
|
|
112
|
+
}),
|
|
113
|
+
numberField: Type.Number({
|
|
114
|
+
messages: {
|
|
115
|
+
required: "Number field is required.",
|
|
116
|
+
default: "Number field must be a valid number."
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
additionalProperties: false,
|
|
122
|
+
messages: {
|
|
123
|
+
additionalProperties: "Unexpected field.",
|
|
124
|
+
default: "Invalid value."
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const recordOutputValidator = Object.freeze({
|
|
130
|
+
schema: recordOutputSchema,
|
|
131
|
+
normalize: normalizeRecordOutput
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const crudResource = {
|
|
135
|
+
resource: "crud",
|
|
136
|
+
messages: {
|
|
137
|
+
validation: "Fix invalid CRUD values and try again.",
|
|
138
|
+
saveSuccess: "Record saved.",
|
|
139
|
+
saveError: "Unable to save record.",
|
|
140
|
+
deleteSuccess: "Record deleted.",
|
|
141
|
+
deleteError: "Unable to delete record."
|
|
142
|
+
},
|
|
143
|
+
operations: {
|
|
144
|
+
list: {
|
|
145
|
+
method: "GET",
|
|
146
|
+
outputValidator: createCursorListValidator(recordOutputValidator)
|
|
147
|
+
},
|
|
148
|
+
view: {
|
|
149
|
+
method: "GET",
|
|
150
|
+
outputValidator: recordOutputValidator
|
|
151
|
+
},
|
|
152
|
+
create: {
|
|
153
|
+
method: "POST",
|
|
154
|
+
bodyValidator: {
|
|
155
|
+
schema: recordBodySchema,
|
|
156
|
+
normalize: normalizeRecordInput
|
|
157
|
+
},
|
|
158
|
+
outputValidator: recordOutputValidator
|
|
159
|
+
},
|
|
160
|
+
patch: {
|
|
161
|
+
method: "PATCH",
|
|
162
|
+
bodyValidator: {
|
|
163
|
+
schema: Type.Partial(recordBodySchema, { additionalProperties: false }),
|
|
164
|
+
normalize: normalizeRecordInput
|
|
165
|
+
},
|
|
166
|
+
outputValidator: recordOutputValidator
|
|
167
|
+
},
|
|
168
|
+
delete: {
|
|
169
|
+
method: "DELETE",
|
|
170
|
+
outputValidator: {
|
|
171
|
+
schema: Type.Object(
|
|
172
|
+
{
|
|
173
|
+
id: Type.Integer({ minimum: 1 }),
|
|
174
|
+
deleted: Type.Literal(true)
|
|
175
|
+
},
|
|
176
|
+
{ additionalProperties: false }
|
|
177
|
+
),
|
|
178
|
+
normalize(payload = {}) {
|
|
179
|
+
const source = normalizeObjectInput(payload);
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
id: Number(source.id),
|
|
183
|
+
deleted: true
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export { crudResource };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { crudResource } from "./crud/crudResource.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const TABLE_NAME = __JSKIT_CRUD_TABLE_NAME__;
|
|
2
|
+
|
|
3
|
+
exports.up = async function up(knex) {
|
|
4
|
+
const hasCrudTable = await knex.schema.hasTable(TABLE_NAME);
|
|
5
|
+
if (hasCrudTable) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
await knex.schema.createTable(TABLE_NAME, (table) => {
|
|
10
|
+
__JSKIT_CRUD_MIGRATION_COLUMN_LINES__
|
|
11
|
+
__JSKIT_CRUD_MIGRATION_INDEX_LINES__
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
exports.down = async function down(knex) {
|
|
16
|
+
await knex.schema.dropTableIfExists(TABLE_NAME);
|
|
17
|
+
};
|