@jskit-ai/crud-server-generator 0.1.30 → 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 +52 -11
- package/package.json +6 -6
- package/src/server/buildTemplateContext.js +204 -32
- package/src/server/subcommands/addField.js +9 -9
- package/src/server/subcommands/resourceAst.js +13 -13
- package/src/shared/crud/crudResource.js +17 -0
- package/templates/src/local-package/server/CrudProvider.js +3 -1
- package/templates/src/local-package/server/actions.js +22 -8
- package/templates/src/local-package/server/listConfig.js +5 -0
- package/templates/src/local-package/server/registerRoutes.js +4 -2
- package/templates/src/local-package/server/repository.js +1 -10
- package/templates/src/local-package/server/service.js +15 -62
- package/templates/src/local-package/shared/crudResource.js +18 -4
- package/templates/src/local-package/shared/index.js +1 -1
- package/test/addFieldSubcommand.test.js +4 -4
- package/test/buildTemplateContext.test.js +186 -9
- package/test/crudServerGuards.test.js +35 -0
- package/test/crudService.test.js +43 -0
- package/test/templateSymbolConsistency.test.js +40 -0
- package/test-support/templateServerFixture.js +10 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
-
cursorPaginationQueryValidator,
|
|
3
2
|
recordIdParamsValidator
|
|
4
3
|
} from "@jskit-ai/kernel/shared/validators";
|
|
5
4
|
import {
|
|
5
|
+
createCrudCursorPaginationQueryValidator,
|
|
6
6
|
listSearchQueryValidator,
|
|
7
7
|
lookupIncludeQueryValidator,
|
|
8
8
|
createCrudParentFilterQueryValidator
|
|
@@ -10,8 +10,17 @@ import {
|
|
|
10
10
|
import { workspaceSlugParamsValidator } from "@jskit-ai/users-core/server/validators/routeParamsValidator";
|
|
11
11
|
import { resource } from "../shared/${option:namespace|singular|camel}Resource.js";
|
|
12
12
|
import { actionIds } from "./actionIds.js";
|
|
13
|
+
import { LIST_CONFIG } from "./listConfig.js";
|
|
13
14
|
|
|
15
|
+
const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator(LIST_CONFIG);
|
|
14
16
|
const listParentFilterQueryValidator = createCrudParentFilterQueryValidator(resource);
|
|
17
|
+
const actionPermissions = Object.freeze({
|
|
18
|
+
list: "crud.${option:namespace|snake}.list",
|
|
19
|
+
view: "crud.${option:namespace|snake}.view",
|
|
20
|
+
create: "crud.${option:namespace|snake}.create",
|
|
21
|
+
update: "crud.${option:namespace|snake}.update",
|
|
22
|
+
delete: "crud.${option:namespace|snake}.delete"
|
|
23
|
+
});
|
|
15
24
|
|
|
16
25
|
function requireActionSurface(surface = "") {
|
|
17
26
|
const normalizedSurface = String(surface || "").trim().toLowerCase();
|
|
@@ -33,11 +42,12 @@ function createActions({ surface = "" } = {}) {
|
|
|
33
42
|
channels: ["api", "automation", "internal"],
|
|
34
43
|
surfaces: [actionSurface],
|
|
35
44
|
permission: {
|
|
36
|
-
require: "
|
|
45
|
+
require: "all",
|
|
46
|
+
permissions: [actionPermissions.list]
|
|
37
47
|
},
|
|
38
48
|
inputValidator: [
|
|
39
49
|
workspaceSlugParamsValidator,
|
|
40
|
-
|
|
50
|
+
listCursorPaginationQueryValidator,
|
|
41
51
|
listSearchQueryValidator,
|
|
42
52
|
listParentFilterQueryValidator,
|
|
43
53
|
lookupIncludeQueryValidator
|
|
@@ -62,10 +72,11 @@ function createActions({ surface = "" } = {}) {
|
|
|
62
72
|
channels: ["api", "automation", "internal"],
|
|
63
73
|
surfaces: [actionSurface],
|
|
64
74
|
permission: {
|
|
65
|
-
require: "
|
|
75
|
+
require: "all",
|
|
76
|
+
permissions: [actionPermissions.view]
|
|
66
77
|
},
|
|
67
78
|
inputValidator: [workspaceSlugParamsValidator, recordIdParamsValidator, lookupIncludeQueryValidator],
|
|
68
|
-
outputValidator:
|
|
79
|
+
outputValidator: resource.operations.view.outputValidator,
|
|
69
80
|
idempotency: "none",
|
|
70
81
|
audit: {
|
|
71
82
|
actionName: actionIds.view
|
|
@@ -86,7 +97,8 @@ function createActions({ surface = "" } = {}) {
|
|
|
86
97
|
channels: ["api", "automation", "internal"],
|
|
87
98
|
surfaces: [actionSurface],
|
|
88
99
|
permission: {
|
|
89
|
-
require: "
|
|
100
|
+
require: "all",
|
|
101
|
+
permissions: [actionPermissions.create]
|
|
90
102
|
},
|
|
91
103
|
inputValidator: [
|
|
92
104
|
workspaceSlugParamsValidator,
|
|
@@ -114,7 +126,8 @@ function createActions({ surface = "" } = {}) {
|
|
|
114
126
|
channels: ["api", "automation", "internal"],
|
|
115
127
|
surfaces: [actionSurface],
|
|
116
128
|
permission: {
|
|
117
|
-
require: "
|
|
129
|
+
require: "all",
|
|
130
|
+
permissions: [actionPermissions.update]
|
|
118
131
|
},
|
|
119
132
|
inputValidator: [
|
|
120
133
|
workspaceSlugParamsValidator,
|
|
@@ -143,7 +156,8 @@ function createActions({ surface = "" } = {}) {
|
|
|
143
156
|
channels: ["api", "automation", "internal"],
|
|
144
157
|
surfaces: [actionSurface],
|
|
145
158
|
permission: {
|
|
146
|
-
require: "
|
|
159
|
+
require: "all",
|
|
160
|
+
permissions: [actionPermissions.delete]
|
|
147
161
|
},
|
|
148
162
|
inputValidator: [workspaceSlugParamsValidator, recordIdParamsValidator],
|
|
149
163
|
outputValidator: resource.operations.delete.outputValidator,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { withStandardErrorResponses } from "@jskit-ai/http-runtime/shared/validators/errorResponses";
|
|
2
2
|
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
|
|
3
3
|
import {
|
|
4
|
+
createCrudCursorPaginationQueryValidator,
|
|
4
5
|
listSearchQueryValidator,
|
|
5
6
|
lookupIncludeQueryValidator,
|
|
6
7
|
createCrudParentFilterQueryValidator
|
|
7
8
|
} from "@jskit-ai/crud-core/server/listQueryValidators";
|
|
8
9
|
import {
|
|
9
|
-
cursorPaginationQueryValidator,
|
|
10
10
|
recordIdParamsValidator
|
|
11
11
|
} from "@jskit-ai/kernel/shared/validators";
|
|
12
12
|
import { routeParamsValidator } from "@jskit-ai/users-core/server/validators/routeParamsValidator";
|
|
@@ -15,7 +15,9 @@ import { buildWorkspaceInputFromRouteParams } from "@jskit-ai/users-core/server/
|
|
|
15
15
|
import { resolveApiBasePath } from "@jskit-ai/users-core/shared/support/usersApiPaths";
|
|
16
16
|
import { actionIds } from "./actionIds.js";
|
|
17
17
|
import { resource } from "../shared/${option:namespace|singular|camel}Resource.js";
|
|
18
|
+
import { LIST_CONFIG } from "./listConfig.js";
|
|
18
19
|
|
|
20
|
+
const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator(LIST_CONFIG);
|
|
19
21
|
const listParentFilterQueryValidator = createCrudParentFilterQueryValidator(resource);
|
|
20
22
|
|
|
21
23
|
function registerRoutes(
|
|
@@ -47,7 +49,7 @@ function registerRoutes(
|
|
|
47
49
|
},
|
|
48
50
|
paramsValidator: routeParamsValidator,
|
|
49
51
|
queryValidator: [
|
|
50
|
-
|
|
52
|
+
listCursorPaginationQueryValidator,
|
|
51
53
|
listSearchQueryValidator,
|
|
52
54
|
listParentFilterQueryValidator,
|
|
53
55
|
lookupIncludeQueryValidator
|
|
@@ -8,12 +8,7 @@ import {
|
|
|
8
8
|
crudRepositoryDeleteById
|
|
9
9
|
} from "@jskit-ai/crud-core/server/repositoryMethods";
|
|
10
10
|
import { resource } from "../shared/${option:namespace|singular|camel}Resource.js";
|
|
11
|
-
|
|
12
|
-
const LIST_CONFIG = Object.freeze({
|
|
13
|
-
// defaultLimit: 20,
|
|
14
|
-
// maxLimit: 100,
|
|
15
|
-
// searchColumns: ["name"]
|
|
16
|
-
});
|
|
11
|
+
import { LIST_CONFIG } from "./listConfig.js";
|
|
17
12
|
|
|
18
13
|
const repositoryRuntime = createCrudRepositoryRuntime(resource, {
|
|
19
14
|
context: "${option:namespace|snake} repository",
|
|
@@ -21,10 +16,6 @@ const repositoryRuntime = createCrudRepositoryRuntime(resource, {
|
|
|
21
16
|
});
|
|
22
17
|
|
|
23
18
|
function createRepository(knex, options = {}) {
|
|
24
|
-
if (typeof knex !== "function") {
|
|
25
|
-
throw new TypeError("crudRepository requires knex.");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
19
|
async function list(query = {}, callOptions = {}) {
|
|
29
20
|
return crudRepositoryList(repositoryRuntime, knex, query, options, callOptions);
|
|
30
21
|
}
|
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
1
|
import { createCrudServiceEvents } from "@jskit-ai/crud-core/server/serviceEvents";
|
|
3
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createCrudServiceRuntime,
|
|
4
|
+
crudServiceListRecords,
|
|
5
|
+
crudServiceGetRecord,
|
|
6
|
+
crudServiceCreateRecord,
|
|
7
|
+
crudServiceUpdateRecord,
|
|
8
|
+
crudServiceDeleteRecord
|
|
9
|
+
} from "@jskit-ai/crud-core/server/serviceMethods";
|
|
4
10
|
import { resource } from "../shared/${option:namespace|singular|camel}Resource.js";
|
|
5
11
|
|
|
6
|
-
const
|
|
12
|
+
const serviceRuntime = createCrudServiceRuntime(resource, {
|
|
7
13
|
context: "${option:namespace|camel}Service"
|
|
8
14
|
});
|
|
9
|
-
const
|
|
15
|
+
const baseServiceEvents = createCrudServiceEvents(resource, {
|
|
10
16
|
context: "${option:namespace|camel}Service"
|
|
11
17
|
});
|
|
12
18
|
|
|
@@ -37,77 +43,24 @@ const DEFAULT_FIELD_ACCESS = Object.freeze({
|
|
|
37
43
|
});
|
|
38
44
|
|
|
39
45
|
function createService({ ${option:namespace|camel}Repository, fieldAccess = DEFAULT_FIELD_ACCESS } = {}) {
|
|
40
|
-
if (!${option:namespace|camel}Repository) {
|
|
41
|
-
throw new Error("${option:namespace|camel}Service requires ${option:namespace|camel}Repository.");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
46
|
async function listRecords(query = {}, options = {}) {
|
|
45
|
-
|
|
46
|
-
return fieldAccessRuntime.filterReadableListResult(result, fieldAccess, {
|
|
47
|
-
action: "list",
|
|
48
|
-
query,
|
|
49
|
-
options,
|
|
50
|
-
context: options?.context
|
|
51
|
-
});
|
|
47
|
+
return crudServiceListRecords(serviceRuntime, ${option:namespace|camel}Repository, fieldAccess, query, options);
|
|
52
48
|
}
|
|
53
49
|
|
|
54
50
|
async function getRecord(recordId, options = {}) {
|
|
55
|
-
|
|
56
|
-
if (!record) {
|
|
57
|
-
throw new AppError(404, "Record not found.");
|
|
58
|
-
}
|
|
59
|
-
return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
|
|
60
|
-
action: "view",
|
|
61
|
-
recordId,
|
|
62
|
-
options,
|
|
63
|
-
context: options?.context
|
|
64
|
-
});
|
|
51
|
+
return crudServiceGetRecord(serviceRuntime, ${option:namespace|camel}Repository, fieldAccess, recordId, options);
|
|
65
52
|
}
|
|
66
53
|
|
|
67
54
|
async function createRecord(payload = {}, options = {}) {
|
|
68
|
-
|
|
69
|
-
action: "create",
|
|
70
|
-
payload,
|
|
71
|
-
options,
|
|
72
|
-
context: options?.context
|
|
73
|
-
});
|
|
74
|
-
const record = await ${option:namespace|camel}Repository.create(writablePayload, options);
|
|
75
|
-
if (!record) {
|
|
76
|
-
throw new Error("${option:namespace|camel}Service could not load the created record.");
|
|
77
|
-
}
|
|
78
|
-
return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
|
|
79
|
-
action: "create",
|
|
80
|
-
options,
|
|
81
|
-
context: options?.context
|
|
82
|
-
});
|
|
55
|
+
return crudServiceCreateRecord(serviceRuntime, ${option:namespace|camel}Repository, fieldAccess, payload, options);
|
|
83
56
|
}
|
|
84
57
|
|
|
85
58
|
async function updateRecord(recordId, payload = {}, options = {}) {
|
|
86
|
-
|
|
87
|
-
action: "update",
|
|
88
|
-
recordId,
|
|
89
|
-
payload,
|
|
90
|
-
options,
|
|
91
|
-
context: options?.context
|
|
92
|
-
});
|
|
93
|
-
const record = await ${option:namespace|camel}Repository.updateById(recordId, writablePayload, options);
|
|
94
|
-
if (!record) {
|
|
95
|
-
throw new AppError(404, "Record not found.");
|
|
96
|
-
}
|
|
97
|
-
return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
|
|
98
|
-
action: "update",
|
|
99
|
-
recordId,
|
|
100
|
-
options,
|
|
101
|
-
context: options?.context
|
|
102
|
-
});
|
|
59
|
+
return crudServiceUpdateRecord(serviceRuntime, ${option:namespace|camel}Repository, fieldAccess, recordId, payload, options);
|
|
103
60
|
}
|
|
104
61
|
|
|
105
62
|
async function deleteRecord(recordId, options = {}) {
|
|
106
|
-
|
|
107
|
-
if (!deleted) {
|
|
108
|
-
throw new AppError(404, "Record not found.");
|
|
109
|
-
}
|
|
110
|
-
return deleted;
|
|
63
|
+
return crudServiceDeleteRecord(serviceRuntime, ${option:namespace|camel}Repository, fieldAccess, recordId, options);
|
|
111
64
|
}
|
|
112
65
|
|
|
113
66
|
return Object.freeze({
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
2
|
__JSKIT_CRUD_RESOURCE_DATABASE_RUNTIME_IMPORT__
|
|
3
|
-
|
|
4
|
-
normalizeObjectInput,
|
|
5
|
-
createCursorListValidator
|
|
6
|
-
} from "@jskit-ai/kernel/shared/validators";
|
|
3
|
+
__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__
|
|
7
4
|
__JSKIT_CRUD_RESOURCE_NORMALIZE_SUPPORT_IMPORT__
|
|
8
5
|
__JSKIT_CRUD_RESOURCE_JSON_IMPORT__
|
|
9
6
|
|
|
@@ -138,4 +135,21 @@ export { resource };
|
|
|
138
135
|
// @jskit-contract crud.resource.field-meta.${option:namespace|snake}.v1
|
|
139
136
|
void RESOURCE_FIELD_META;
|
|
140
137
|
|
|
138
|
+
// Example 1:n collection hydration:
|
|
139
|
+
// RESOURCE_FIELD_META.push({
|
|
140
|
+
// key: "pets",
|
|
141
|
+
// relation: {
|
|
142
|
+
// kind: "collection",
|
|
143
|
+
// namespace: "pets",
|
|
144
|
+
// foreignKey: "customerId",
|
|
145
|
+
// parentValueKey: "id",
|
|
146
|
+
// hydrateOnList: false, // list: opt-in with include=pets
|
|
147
|
+
// hydrateOnView: true // view: hydrated by default
|
|
148
|
+
// }
|
|
149
|
+
// });
|
|
150
|
+
//
|
|
151
|
+
// To hydrate child lookups too, request nested include paths:
|
|
152
|
+
// - include=pets
|
|
153
|
+
// - include=pets,pets.breedId
|
|
154
|
+
|
|
141
155
|
__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__
|
|
@@ -86,7 +86,7 @@ export { resource };
|
|
|
86
86
|
`;
|
|
87
87
|
|
|
88
88
|
async function withTempApp(run) {
|
|
89
|
-
const appRoot = await mkdtemp(path.join(tmpdir(), "crud-server-
|
|
89
|
+
const appRoot = await mkdtemp(path.join(tmpdir(), "crud-server-scaffold-field-"));
|
|
90
90
|
try {
|
|
91
91
|
await run(appRoot);
|
|
92
92
|
} finally {
|
|
@@ -129,14 +129,14 @@ function createSnapshot() {
|
|
|
129
129
|
};
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
test("
|
|
132
|
+
test("scaffold-field patches CRUD resource file using DB snapshot metadata", async () => {
|
|
133
133
|
await withTempApp(async (appRoot) => {
|
|
134
134
|
const resourceFile = "packages/contacts/src/shared/contactResource.js";
|
|
135
135
|
await writeAppFile(appRoot, resourceFile, RESOURCE_SOURCE);
|
|
136
136
|
|
|
137
137
|
const result = await runGeneratorSubcommand({
|
|
138
138
|
appRoot,
|
|
139
|
-
subcommand: "
|
|
139
|
+
subcommand: "scaffold-field",
|
|
140
140
|
args: ["categoryId", resourceFile],
|
|
141
141
|
options: {},
|
|
142
142
|
resolveSnapshot: async () => createSnapshot()
|
|
@@ -157,7 +157,7 @@ test("add-field patches CRUD resource file using DB snapshot metadata", async ()
|
|
|
157
157
|
|
|
158
158
|
const secondRun = await runGeneratorSubcommand({
|
|
159
159
|
appRoot,
|
|
160
|
-
subcommand: "
|
|
160
|
+
subcommand: "scaffold-field",
|
|
161
161
|
args: ["categoryId", resourceFile],
|
|
162
162
|
options: {},
|
|
163
163
|
resolveSnapshot: async () => createSnapshot()
|
|
@@ -9,8 +9,30 @@ import { buildTemplateContext, __testables } from "../src/server/buildTemplateCo
|
|
|
9
9
|
function createSnapshot({
|
|
10
10
|
tableName = "contacts",
|
|
11
11
|
hasWorkspaceOwnerColumn = true,
|
|
12
|
-
hasUserOwnerColumn = true
|
|
12
|
+
hasUserOwnerColumn = true,
|
|
13
|
+
hasCreatedAtColumn = true
|
|
13
14
|
} = {}) {
|
|
15
|
+
const createdAtColumn = hasCreatedAtColumn
|
|
16
|
+
? [
|
|
17
|
+
Object.freeze({
|
|
18
|
+
name: "created_at",
|
|
19
|
+
key: "createdAt",
|
|
20
|
+
dataType: "datetime",
|
|
21
|
+
columnType: "datetime",
|
|
22
|
+
typeKind: "datetime",
|
|
23
|
+
nullable: false,
|
|
24
|
+
hasDefault: true,
|
|
25
|
+
defaultValue: "CURRENT_TIMESTAMP",
|
|
26
|
+
autoIncrement: false,
|
|
27
|
+
unsigned: false,
|
|
28
|
+
extra: "",
|
|
29
|
+
maxLength: null,
|
|
30
|
+
numericPrecision: null,
|
|
31
|
+
numericScale: null,
|
|
32
|
+
enumValues: Object.freeze([])
|
|
33
|
+
})
|
|
34
|
+
]
|
|
35
|
+
: [];
|
|
14
36
|
return Object.freeze({
|
|
15
37
|
tableName,
|
|
16
38
|
idColumn: "id",
|
|
@@ -86,6 +108,7 @@ function createSnapshot({
|
|
|
86
108
|
numericScale: null,
|
|
87
109
|
enumValues: Object.freeze([])
|
|
88
110
|
}),
|
|
111
|
+
...createdAtColumn,
|
|
89
112
|
Object.freeze({
|
|
90
113
|
name: "updated_at",
|
|
91
114
|
key: "updatedAt",
|
|
@@ -223,6 +246,14 @@ test("buildReplacementsFromSnapshot builds deterministic template replacement pa
|
|
|
223
246
|
replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_NORMALIZATION_LINES__,
|
|
224
247
|
/firstName: normalizeIfPresent\(source\.firstName, normalizeText\),/
|
|
225
248
|
);
|
|
249
|
+
assert.match(
|
|
250
|
+
replacements.__JSKIT_CRUD_LIST_CONFIG_LINES__,
|
|
251
|
+
/orderBy: \[\s+{\s+column: "created_at",\s+direction: "desc"\s+}\s+\]/s
|
|
252
|
+
);
|
|
253
|
+
assert.match(
|
|
254
|
+
replacements.__JSKIT_CRUD_LIST_CONFIG_LINES__,
|
|
255
|
+
/\/\/ searchColumns: \["name"\],\s+orderBy:/s
|
|
256
|
+
);
|
|
226
257
|
assert.doesNotMatch(
|
|
227
258
|
replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_NORMALIZATION_LINES__,
|
|
228
259
|
/== null \?/
|
|
@@ -231,6 +262,20 @@ test("buildReplacementsFromSnapshot builds deterministic template replacement pa
|
|
|
231
262
|
assert.equal(replacements.__JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__, "");
|
|
232
263
|
});
|
|
233
264
|
|
|
265
|
+
test("buildReplacementsFromSnapshot omits default list ordering when created_at is absent", () => {
|
|
266
|
+
const snapshot = createSnapshot({
|
|
267
|
+
hasCreatedAtColumn: false
|
|
268
|
+
});
|
|
269
|
+
const replacements = __testables.buildReplacementsFromSnapshot({
|
|
270
|
+
namespace: "contacts",
|
|
271
|
+
snapshot,
|
|
272
|
+
resolvedOwnershipFilter: "workspace_user"
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
assert.doesNotMatch(replacements.__JSKIT_CRUD_LIST_CONFIG_LINES__, /orderBy/);
|
|
276
|
+
assert.match(replacements.__JSKIT_CRUD_LIST_CONFIG_LINES__, /searchColumns/);
|
|
277
|
+
});
|
|
278
|
+
|
|
234
279
|
test("buildReplacementsFromSnapshot renders append-only field meta entries from foreign keys", () => {
|
|
235
280
|
const snapshot = {
|
|
236
281
|
...createSnapshot(),
|
|
@@ -285,6 +330,47 @@ test("buildReplacementsFromSnapshot renders append-only field meta entries from
|
|
|
285
330
|
assert.match(replacements.__JSKIT_CRUD_MIGRATION_FOREIGN_KEY_LINES__, /table\.foreign\(\["vet_id"\]/);
|
|
286
331
|
});
|
|
287
332
|
|
|
333
|
+
test("buildReplacementsFromSnapshot renders enum field meta options as select controls", () => {
|
|
334
|
+
const baseSnapshot = createSnapshot({
|
|
335
|
+
hasWorkspaceOwnerColumn: false,
|
|
336
|
+
hasUserOwnerColumn: false
|
|
337
|
+
});
|
|
338
|
+
const snapshot = {
|
|
339
|
+
...baseSnapshot,
|
|
340
|
+
columns: Object.freeze([
|
|
341
|
+
...baseSnapshot.columns,
|
|
342
|
+
Object.freeze({
|
|
343
|
+
name: "temperament",
|
|
344
|
+
key: "temperament",
|
|
345
|
+
dataType: "enum",
|
|
346
|
+
columnType: "enum('relaxed','friendly_excitable','unknown')",
|
|
347
|
+
typeKind: "string",
|
|
348
|
+
nullable: false,
|
|
349
|
+
hasDefault: false,
|
|
350
|
+
defaultValue: null,
|
|
351
|
+
autoIncrement: false,
|
|
352
|
+
unsigned: false,
|
|
353
|
+
extra: "",
|
|
354
|
+
maxLength: null,
|
|
355
|
+
numericPrecision: null,
|
|
356
|
+
numericScale: null,
|
|
357
|
+
enumValues: Object.freeze(["relaxed", "friendly_excitable", "unknown"])
|
|
358
|
+
})
|
|
359
|
+
])
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
const replacements = __testables.buildReplacementsFromSnapshot({
|
|
363
|
+
snapshot,
|
|
364
|
+
resolvedOwnershipFilter: "public"
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /key: "temperament"/);
|
|
368
|
+
assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /formControl: "select"/);
|
|
369
|
+
assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /options: \[/);
|
|
370
|
+
assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /"value": "friendly_excitable"/);
|
|
371
|
+
assert.match(replacements.__JSKIT_CRUD_RESOURCE_FIELD_META_PUSH_LINES__, /"label": "Friendly Excitable"/);
|
|
372
|
+
});
|
|
373
|
+
|
|
288
374
|
test("renderMigrationColumnLine ignores SQL NULL string defaults", () => {
|
|
289
375
|
const line = __testables.renderMigrationColumnLine(
|
|
290
376
|
{
|
|
@@ -403,6 +489,7 @@ test("crud repository template defines explicit one-line CRUD methods over repos
|
|
|
403
489
|
templateSource,
|
|
404
490
|
/from "@jskit-ai\/crud-core\/server\/repositoryMethods";/
|
|
405
491
|
);
|
|
492
|
+
assert.match(templateSource, /import \{ LIST_CONFIG \} from "\.\/listConfig\.js";/);
|
|
406
493
|
assert.match(templateSource, /const repositoryRuntime = createCrudRepositoryRuntime\(/);
|
|
407
494
|
assert.match(templateSource, /return crudRepositoryList\(repositoryRuntime, knex, query, options, callOptions\);/);
|
|
408
495
|
assert.match(templateSource, /return crudRepositoryFindById\(repositoryRuntime, knex, recordId, options, callOptions\);/);
|
|
@@ -410,9 +497,31 @@ test("crud repository template defines explicit one-line CRUD methods over repos
|
|
|
410
497
|
assert.match(templateSource, /return crudRepositoryCreate\(repositoryRuntime, knex, payload, options, callOptions\);/);
|
|
411
498
|
assert.match(templateSource, /return crudRepositoryUpdateById\(repositoryRuntime, knex, recordId, patch, options, callOptions\);/);
|
|
412
499
|
assert.match(templateSource, /return crudRepositoryDeleteById\(repositoryRuntime, knex, recordId, options, callOptions\);/);
|
|
500
|
+
assert.doesNotMatch(templateSource, /listByForeignIds/);
|
|
501
|
+
assert.doesNotMatch(templateSource, /crudRepository requires knex/);
|
|
413
502
|
});
|
|
414
503
|
|
|
415
|
-
test("crud
|
|
504
|
+
test("crud actions and routes templates share LIST_CONFIG for cursor validation", async () => {
|
|
505
|
+
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
506
|
+
const actionsTemplatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "actions.js");
|
|
507
|
+
const registerRoutesTemplatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "registerRoutes.js");
|
|
508
|
+
const listConfigTemplatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "listConfig.js");
|
|
509
|
+
|
|
510
|
+
const actionsTemplateSource = await readFile(actionsTemplatePath, "utf8");
|
|
511
|
+
const registerRoutesTemplateSource = await readFile(registerRoutesTemplatePath, "utf8");
|
|
512
|
+
const listConfigTemplateSource = await readFile(listConfigTemplatePath, "utf8");
|
|
513
|
+
|
|
514
|
+
assert.match(actionsTemplateSource, /createCrudCursorPaginationQueryValidator/);
|
|
515
|
+
assert.match(actionsTemplateSource, /import \{ LIST_CONFIG \} from "\.\/listConfig\.js";/);
|
|
516
|
+
assert.match(actionsTemplateSource, /const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator\(LIST_CONFIG\);/);
|
|
517
|
+
assert.match(registerRoutesTemplateSource, /createCrudCursorPaginationQueryValidator/);
|
|
518
|
+
assert.match(registerRoutesTemplateSource, /import \{ LIST_CONFIG \} from "\.\/listConfig\.js";/);
|
|
519
|
+
assert.match(registerRoutesTemplateSource, /const listCursorPaginationQueryValidator = createCrudCursorPaginationQueryValidator\(LIST_CONFIG\);/);
|
|
520
|
+
assert.match(listConfigTemplateSource, /const LIST_CONFIG = Object\.freeze\(\{/);
|
|
521
|
+
assert.match(listConfigTemplateSource, /__JSKIT_CRUD_LIST_CONFIG_LINES__/);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("crud service template defines explicit service methods over shared service primitives and preserves overridable default events", async () => {
|
|
416
525
|
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
417
526
|
const templatePath = path.resolve(testDirectory, "..", "templates", "src", "local-package", "server", "service.js");
|
|
418
527
|
const templateSource = await readFile(templatePath, "utf8");
|
|
@@ -423,16 +532,81 @@ test("crud service template defines explicit service methods and semi-explicit d
|
|
|
423
532
|
);
|
|
424
533
|
assert.match(
|
|
425
534
|
templateSource,
|
|
426
|
-
/from "@jskit-ai\/crud-core\/server\/
|
|
535
|
+
/from "@jskit-ai\/crud-core\/server\/serviceMethods";/
|
|
427
536
|
);
|
|
428
|
-
assert.match(templateSource, /const
|
|
429
|
-
assert.match(templateSource, /const
|
|
537
|
+
assert.match(templateSource, /const serviceRuntime = createCrudServiceRuntime\(resource,/);
|
|
538
|
+
assert.match(templateSource, /const baseServiceEvents = createCrudServiceEvents\(resource,/);
|
|
430
539
|
assert.match(templateSource, /const serviceEvents = Object\.freeze\(\{/);
|
|
431
540
|
assert.match(templateSource, /createRecord: \[\.\.\.baseServiceEvents\.createRecord\],/);
|
|
541
|
+
assert.match(templateSource, /function createService\(\{ \$\{option:namespace\|camel\}Repository, fieldAccess = DEFAULT_FIELD_ACCESS \} = \{\}\)/);
|
|
432
542
|
assert.match(templateSource, /async function listRecords\(query = \{\}, options = \{\}\)/);
|
|
433
|
-
assert.match(templateSource, /return
|
|
434
|
-
assert.match(templateSource, /
|
|
435
|
-
assert.match(templateSource, /
|
|
543
|
+
assert.match(templateSource, /return crudServiceListRecords\(serviceRuntime, \$\{option:namespace\|camel\}Repository, fieldAccess, query, options\);/);
|
|
544
|
+
assert.match(templateSource, /async function updateRecord\(recordId, payload = \{\}, options = \{\}\)/);
|
|
545
|
+
assert.match(templateSource, /return crudServiceUpdateRecord\(serviceRuntime, \$\{option:namespace\|camel\}Repository, fieldAccess, recordId, payload, options\);/);
|
|
546
|
+
assert.match(templateSource, /return Object\.freeze\(\{/);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("crud generator renders time columns with html-time-compatible schemas", async () => {
|
|
550
|
+
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
|
|
551
|
+
const templatePath = path.resolve(testDirectory, "..", "src", "server", "buildTemplateContext.js");
|
|
552
|
+
const templateSource = await readFile(templatePath, "utf8");
|
|
553
|
+
|
|
554
|
+
assert.match(
|
|
555
|
+
templateSource,
|
|
556
|
+
/NULLABLE_HTML_TIME_STRING_SCHEMA/
|
|
557
|
+
);
|
|
558
|
+
assert.match(
|
|
559
|
+
templateSource,
|
|
560
|
+
/HTML_TIME_STRING_SCHEMA/
|
|
561
|
+
);
|
|
562
|
+
assert.doesNotMatch(templateSource, /format: "time"/);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("buildReplacementsFromSnapshot uses shared framework time schemas in generated resources", () => {
|
|
566
|
+
const snapshot = createSnapshot({
|
|
567
|
+
tableName: "opening_hours"
|
|
568
|
+
});
|
|
569
|
+
const timeColumn = Object.freeze({
|
|
570
|
+
name: "from_time",
|
|
571
|
+
key: "fromTime",
|
|
572
|
+
dataType: "time",
|
|
573
|
+
columnType: "time",
|
|
574
|
+
typeKind: "time",
|
|
575
|
+
nullable: true,
|
|
576
|
+
hasDefault: false,
|
|
577
|
+
defaultValue: null,
|
|
578
|
+
autoIncrement: false,
|
|
579
|
+
unsigned: false,
|
|
580
|
+
extra: "",
|
|
581
|
+
maxLength: null,
|
|
582
|
+
numericPrecision: null,
|
|
583
|
+
numericScale: null,
|
|
584
|
+
enumValues: Object.freeze([])
|
|
585
|
+
});
|
|
586
|
+
const replacements = __testables.buildReplacementsFromSnapshot({
|
|
587
|
+
snapshot: {
|
|
588
|
+
...snapshot,
|
|
589
|
+
columns: Object.freeze([...snapshot.columns, timeColumn])
|
|
590
|
+
},
|
|
591
|
+
resolvedOwnershipFilter: "workspace_user"
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
assert.match(
|
|
595
|
+
replacements.__JSKIT_CRUD_RESOURCE_VALIDATORS_IMPORT__,
|
|
596
|
+
/NULLABLE_HTML_TIME_STRING_SCHEMA/
|
|
597
|
+
);
|
|
598
|
+
assert.match(
|
|
599
|
+
replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__,
|
|
600
|
+
/fromTime: NULLABLE_HTML_TIME_STRING_SCHEMA/
|
|
601
|
+
);
|
|
602
|
+
assert.match(
|
|
603
|
+
replacements.__JSKIT_CRUD_RESOURCE_CREATE_SCHEMA_PROPERTIES__,
|
|
604
|
+
/fromTime: NULLABLE_HTML_TIME_STRING_SCHEMA/
|
|
605
|
+
);
|
|
606
|
+
assert.doesNotMatch(
|
|
607
|
+
replacements.__JSKIT_CRUD_RESOURCE_OUTPUT_SCHEMA_PROPERTIES__,
|
|
608
|
+
/Type\.String\(\{ pattern:/
|
|
609
|
+
);
|
|
436
610
|
});
|
|
437
611
|
|
|
438
612
|
test("crud provider template uses shared lookup provider helpers instead of inline wiring", async () => {
|
|
@@ -445,6 +619,9 @@ test("crud provider template uses shared lookup provider helpers instead of inli
|
|
|
445
619
|
/from "@jskit-ai\/crud-core\/server\/lookupProviders";/
|
|
446
620
|
);
|
|
447
621
|
assert.match(templateSource, /resolveLookupProvider: createCrudLookupProviderResolver\(scope\)/);
|
|
448
|
-
assert.match(
|
|
622
|
+
assert.match(
|
|
623
|
+
templateSource,
|
|
624
|
+
/return createCrudLookupProvider\(scope\.make\("repository\.\$\{option:namespace\|snake\}"\), \{\s*ownershipFilter: crudPolicy\.ownershipFilter\s*\}\);/
|
|
625
|
+
);
|
|
449
626
|
assert.doesNotMatch(templateSource, /normalizePathname\(relation\.apiPath\)/);
|
|
450
627
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import test, { after } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { createTemplateServerFixture } from "../test-support/templateServerFixture.js";
|
|
4
|
+
import descriptor from "../package.descriptor.mjs";
|
|
4
5
|
|
|
5
6
|
const fixture = await createTemplateServerFixture();
|
|
6
7
|
const { createActions } = await fixture.importServerModule("actions.js");
|
|
@@ -53,3 +54,37 @@ test("template createActions requires explicit surface", () => {
|
|
|
53
54
|
/requires a non-empty surface/
|
|
54
55
|
);
|
|
55
56
|
});
|
|
57
|
+
|
|
58
|
+
test("template createActions requires namespaced CRUD permissions by default", () => {
|
|
59
|
+
const actions = createActions({ surface: "admin" });
|
|
60
|
+
|
|
61
|
+
assert.deepEqual(
|
|
62
|
+
actions.map((action) => action.permission),
|
|
63
|
+
[
|
|
64
|
+
{ require: "all", permissions: ["crud.customers.list"] },
|
|
65
|
+
{ require: "all", permissions: ["crud.customers.view"] },
|
|
66
|
+
{ require: "all", permissions: ["crud.customers.create"] },
|
|
67
|
+
{ require: "all", permissions: ["crud.customers.update"] },
|
|
68
|
+
{ require: "all", permissions: ["crud.customers.delete"] }
|
|
69
|
+
]
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("crud generator appends member role grants for generated CRUD permissions", () => {
|
|
74
|
+
assert.deepEqual(
|
|
75
|
+
descriptor.mutations.text,
|
|
76
|
+
[
|
|
77
|
+
{
|
|
78
|
+
op: "append-text",
|
|
79
|
+
file: "config/roles.js",
|
|
80
|
+
position: "bottom",
|
|
81
|
+
skipIfContains: "\"crud.${option:namespace|snake}.list\"",
|
|
82
|
+
value:
|
|
83
|
+
"\nroleCatalog.roles.member.permissions.push(\n \"crud.${option:namespace|snake}.list\",\n \"crud.${option:namespace|snake}.view\",\n \"crud.${option:namespace|snake}.create\",\n \"crud.${option:namespace|snake}.update\",\n \"crud.${option:namespace|snake}.delete\"\n);\n",
|
|
84
|
+
reason: "Grant generated CRUD action permissions to the default member role in the app-owned role catalog.",
|
|
85
|
+
category: "crud",
|
|
86
|
+
id: "crud-role-catalog-permissions-${option:namespace|snake}"
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
);
|
|
90
|
+
});
|