@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.
@@ -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
+ };