@jskit-ai/crud 0.1.4

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.
Files changed (40) hide show
  1. package/package.descriptor.mjs +322 -0
  2. package/package.json +22 -0
  3. package/src/client/index.js +3 -0
  4. package/src/server/CrudServiceProvider.js +11 -0
  5. package/src/server/actionIds.js +22 -0
  6. package/src/server/actions.js +152 -0
  7. package/src/server/registerRoutes.js +235 -0
  8. package/src/server/repository.js +162 -0
  9. package/src/server/service.js +96 -0
  10. package/src/shared/crud/crudModuleConfig.js +310 -0
  11. package/src/shared/crud/crudResource.js +191 -0
  12. package/src/shared/index.js +12 -0
  13. package/templates/migrations/crud_initial.cjs +42 -0
  14. package/templates/src/elements/CreateElement.vue +115 -0
  15. package/templates/src/elements/EditElement.vue +140 -0
  16. package/templates/src/elements/ListElement.vue +88 -0
  17. package/templates/src/elements/ViewElement.vue +126 -0
  18. package/templates/src/elements/clientSupport.js +41 -0
  19. package/templates/src/local-package/client/index.js +4 -0
  20. package/templates/src/local-package/package.descriptor.mjs +83 -0
  21. package/templates/src/local-package/package.json +14 -0
  22. package/templates/src/local-package/server/CrudServiceProvider.js +87 -0
  23. package/templates/src/local-package/server/actionIds.js +9 -0
  24. package/templates/src/local-package/server/actions.js +151 -0
  25. package/templates/src/local-package/server/diTokens.js +4 -0
  26. package/templates/src/local-package/server/registerRoutes.js +196 -0
  27. package/templates/src/local-package/server/repository.js +1 -0
  28. package/templates/src/local-package/server/service.js +96 -0
  29. package/templates/src/local-package/shared/crudResource.js +1 -0
  30. package/templates/src/local-package/shared/index.js +8 -0
  31. package/templates/src/local-package/shared/moduleConfig.js +169 -0
  32. package/templates/src/pages/admin/crud/[recordId]/edit.vue +7 -0
  33. package/templates/src/pages/admin/crud/[recordId]/index.vue +7 -0
  34. package/templates/src/pages/admin/crud/index.vue +7 -0
  35. package/templates/src/pages/admin/crud/new.vue +7 -0
  36. package/test/crudModuleConfig.test.js +225 -0
  37. package/test/crudResource.test.js +41 -0
  38. package/test/crudServerGuards.test.js +61 -0
  39. package/test/crudService.test.js +83 -0
  40. package/test/routeInputContracts.test.js +211 -0
@@ -0,0 +1,235 @@
1
+ import { withStandardErrorResponses } from "@jskit-ai/http-runtime/shared/validators/errorResponses";
2
+ import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
3
+ import { KERNEL_TOKENS } from "@jskit-ai/kernel/shared/support/tokens";
4
+ import {
5
+ cursorPaginationQueryValidator,
6
+ recordIdParamsValidator
7
+ } from "@jskit-ai/kernel/shared/validators";
8
+ import { routeParamsValidator } from "@jskit-ai/users-core/server/validators/routeParamsValidator";
9
+ import { normalizeScopedRouteVisibility } from "@jskit-ai/users-core/shared/support/usersVisibility";
10
+ import { buildWorkspaceInputFromRouteParams } from "@jskit-ai/users-core/server/support/workspaceRouteInput";
11
+ import { resolveApiBasePath } from "@jskit-ai/users-core/shared/support/usersApiPaths";
12
+ import { crudResource } from "../shared/crud/crudResource.js";
13
+
14
+ function joinRoutePath(basePath = "", suffix = "") {
15
+ const base = String(basePath || "").trim().replace(/\/+$/g, "");
16
+ const end = String(suffix || "").trim();
17
+ if (!end) {
18
+ return base;
19
+ }
20
+
21
+ return `${base}/${end.replace(/^\/+/, "")}`;
22
+ }
23
+
24
+ function requireRouteRelativePath(routeRelativePath) {
25
+ const routePath = String(routeRelativePath || "").trim();
26
+ if (!routePath) {
27
+ throw new TypeError("registerRoutes requires routeRelativePath.");
28
+ }
29
+
30
+ return routePath;
31
+ }
32
+
33
+ function requireActionIds(actionIds) {
34
+ const source = actionIds && typeof actionIds === "object" && !Array.isArray(actionIds) ? actionIds : null;
35
+ if (!source) {
36
+ throw new TypeError("registerRoutes requires actionIds.");
37
+ }
38
+
39
+ const requiredKeys = ["list", "view", "create", "update", "delete"];
40
+ const normalized = {};
41
+ for (const key of requiredKeys) {
42
+ const value = String(source[key] || "").trim();
43
+ if (!value) {
44
+ throw new TypeError(`registerRoutes requires actionIds.${key}.`);
45
+ }
46
+ normalized[key] = value;
47
+ }
48
+
49
+ return Object.freeze(normalized);
50
+ }
51
+
52
+ function registerRoutes(
53
+ app,
54
+ {
55
+ routeRelativePath,
56
+ routeOwnershipFilter = "public",
57
+ routeSurface = "",
58
+ routeSurfaceRequiresWorkspace = false,
59
+ actionIds
60
+ } = {}
61
+ ) {
62
+ if (!app || typeof app.make !== "function") {
63
+ throw new Error("registerRoutes requires application make().");
64
+ }
65
+
66
+ const router = app.make(KERNEL_TOKENS.HttpRouter);
67
+ const relativePath = requireRouteRelativePath(routeRelativePath);
68
+ const routeBase = resolveApiBasePath({
69
+ surfaceRequiresWorkspace: routeSurfaceRequiresWorkspace === true,
70
+ relativePath
71
+ });
72
+ const routeVisibility = normalizeScopedRouteVisibility(routeOwnershipFilter, {
73
+ fallback: "public"
74
+ });
75
+ const surface = normalizeSurfaceId(routeSurface);
76
+ const resolvedActionIds = requireActionIds(actionIds);
77
+
78
+ router.register(
79
+ "GET",
80
+ routeBase,
81
+ {
82
+ auth: "required",
83
+ surface,
84
+ visibility: routeVisibility,
85
+ meta: {
86
+ tags: ["crud"],
87
+ summary: "List records."
88
+ },
89
+ paramsValidator: routeParamsValidator,
90
+ queryValidator: cursorPaginationQueryValidator,
91
+ responseValidators: withStandardErrorResponses({
92
+ 200: crudResource.operations.list.outputValidator
93
+ })
94
+ },
95
+ async function (request, reply) {
96
+ const listInput = {
97
+ ...buildWorkspaceInputFromRouteParams(request.input.params)
98
+ };
99
+ if (request.input.query.cursor != null) {
100
+ listInput.cursor = request.input.query.cursor;
101
+ }
102
+ if (request.input.query.limit != null) {
103
+ listInput.limit = request.input.query.limit;
104
+ }
105
+ const response = await request.executeAction({
106
+ actionId: resolvedActionIds.list,
107
+ input: listInput
108
+ });
109
+ reply.code(200).send(response);
110
+ }
111
+ );
112
+
113
+ router.register(
114
+ "GET",
115
+ joinRoutePath(routeBase, ":recordId"),
116
+ {
117
+ auth: "required",
118
+ surface,
119
+ visibility: routeVisibility,
120
+ meta: {
121
+ tags: ["crud"],
122
+ summary: "View a record."
123
+ },
124
+ paramsValidator: [routeParamsValidator, recordIdParamsValidator],
125
+ responseValidators: withStandardErrorResponses({
126
+ 200: crudResource.operations.view.outputValidator
127
+ })
128
+ },
129
+ async function (request, reply) {
130
+ const response = await request.executeAction({
131
+ actionId: resolvedActionIds.view,
132
+ input: {
133
+ ...buildWorkspaceInputFromRouteParams(request.input.params),
134
+ recordId: request.input.params.recordId
135
+ }
136
+ });
137
+ reply.code(200).send(response);
138
+ }
139
+ );
140
+
141
+ router.register(
142
+ "POST",
143
+ routeBase,
144
+ {
145
+ auth: "required",
146
+ surface,
147
+ visibility: routeVisibility,
148
+ meta: {
149
+ tags: ["crud"],
150
+ summary: "Create a record."
151
+ },
152
+ paramsValidator: routeParamsValidator,
153
+ bodyValidator: crudResource.operations.create.bodyValidator,
154
+ responseValidators: withStandardErrorResponses(
155
+ {
156
+ 201: crudResource.operations.create.outputValidator
157
+ },
158
+ { includeValidation400: true }
159
+ )
160
+ },
161
+ async function (request, reply) {
162
+ const response = await request.executeAction({
163
+ actionId: resolvedActionIds.create,
164
+ input: {
165
+ ...buildWorkspaceInputFromRouteParams(request.input.params),
166
+ payload: request.input.body
167
+ }
168
+ });
169
+ reply.code(201).send(response);
170
+ }
171
+ );
172
+
173
+ router.register(
174
+ "PATCH",
175
+ joinRoutePath(routeBase, ":recordId"),
176
+ {
177
+ auth: "required",
178
+ surface,
179
+ visibility: routeVisibility,
180
+ meta: {
181
+ tags: ["crud"],
182
+ summary: "Update a record."
183
+ },
184
+ paramsValidator: [routeParamsValidator, recordIdParamsValidator],
185
+ bodyValidator: crudResource.operations.patch.bodyValidator,
186
+ responseValidators: withStandardErrorResponses(
187
+ {
188
+ 200: crudResource.operations.patch.outputValidator
189
+ },
190
+ { includeValidation400: true }
191
+ )
192
+ },
193
+ async function (request, reply) {
194
+ const response = await request.executeAction({
195
+ actionId: resolvedActionIds.update,
196
+ input: {
197
+ ...buildWorkspaceInputFromRouteParams(request.input.params),
198
+ recordId: request.input.params.recordId,
199
+ patch: request.input.body
200
+ }
201
+ });
202
+ reply.code(200).send(response);
203
+ }
204
+ );
205
+
206
+ router.register(
207
+ "DELETE",
208
+ joinRoutePath(routeBase, ":recordId"),
209
+ {
210
+ auth: "required",
211
+ surface,
212
+ visibility: routeVisibility,
213
+ meta: {
214
+ tags: ["crud"],
215
+ summary: "Delete a record."
216
+ },
217
+ paramsValidator: [routeParamsValidator, recordIdParamsValidator],
218
+ responseValidators: withStandardErrorResponses({
219
+ 200: crudResource.operations.delete.outputValidator
220
+ })
221
+ },
222
+ async function (request, reply) {
223
+ const response = await request.executeAction({
224
+ actionId: resolvedActionIds.delete,
225
+ input: {
226
+ ...buildWorkspaceInputFromRouteParams(request.input.params),
227
+ recordId: request.input.params.recordId
228
+ }
229
+ });
230
+ reply.code(200).send(response);
231
+ }
232
+ );
233
+ }
234
+
235
+ 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 };