@jskit-ai/crud-server-generator 0.1.26 → 0.1.28
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 +19 -14
- package/package.json +9 -7
- package/src/server/{CrudServiceProvider.js → CrudProvider.js} +2 -2
- package/src/server/buildTemplateContext.js +278 -32
- package/src/server/subcommands/addField.js +238 -0
- package/src/server/subcommands/resourceAst.js +632 -0
- package/src/shared/crud/crudResource.js +93 -98
- package/templates/migrations/crud_initial.cjs +1 -0
- package/templates/src/local-package/package.descriptor.mjs +2 -2
- package/templates/src/local-package/server/{CrudServiceProvider.js → CrudProvider.js} +13 -8
- package/templates/src/local-package/server/actions.js +24 -10
- package/templates/src/local-package/server/registerRoutes.js +32 -33
- package/templates/src/local-package/server/repository.js +33 -132
- package/templates/src/local-package/server/service.js +88 -47
- package/templates/src/local-package/shared/crudResource.js +77 -45
- package/test/addFieldSubcommand.test.js +167 -0
- package/test/buildTemplateContext.test.js +198 -4
- package/test/crudResource.test.js +6 -0
- package/test/crudServerGuards.test.js +43 -49
- package/test/crudService.test.js +93 -5
- package/test/routeInputContracts.test.js +144 -41
- package/test-support/templateServerFixture.js +169 -0
- package/src/server/actionIds.js +0 -22
- package/src/server/actions.js +0 -152
- package/src/server/registerRoutes.js +0 -234
- package/src/server/repository.js +0 -162
- package/src/server/service.js +0 -96
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { normalizeText } from "@jskit-ai/database-runtime/shared";
|
|
4
|
+
import { toCamelCase } from "@jskit-ai/kernel/shared/support/stringCase";
|
|
5
|
+
import {
|
|
6
|
+
resolveGenerationSnapshot,
|
|
7
|
+
resolveScaffoldColumns,
|
|
8
|
+
renderPropertyAccess,
|
|
9
|
+
renderResourceFieldSchema,
|
|
10
|
+
renderInputNormalizer,
|
|
11
|
+
renderOutputNormalizerExpression,
|
|
12
|
+
buildFieldMetaEntries
|
|
13
|
+
} from "../buildTemplateContext.js";
|
|
14
|
+
import {
|
|
15
|
+
resolveCrudResourceDefaults,
|
|
16
|
+
applyCrudResourceFieldPatch
|
|
17
|
+
} from "./resourceAst.js";
|
|
18
|
+
|
|
19
|
+
const NORMALIZE_SUPPORT_IMPORTS = new Set([
|
|
20
|
+
"normalizeText",
|
|
21
|
+
"normalizeBoolean",
|
|
22
|
+
"normalizeFiniteNumber",
|
|
23
|
+
"normalizeFiniteInteger",
|
|
24
|
+
"normalizeIfInSource",
|
|
25
|
+
"normalizeIfPresent",
|
|
26
|
+
"normalizeOrNull"
|
|
27
|
+
]);
|
|
28
|
+
const DATABASE_RUNTIME_IMPORTS = new Set(["toIsoString", "toDatabaseDateTimeUtc"]);
|
|
29
|
+
const DATABASE_RUNTIME_REPOSITORY_IMPORTS = new Set(["parseJsonValue"]);
|
|
30
|
+
|
|
31
|
+
function toPosixPath(value = "") {
|
|
32
|
+
return String(value || "").replaceAll(path.sep, "/");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveTargetFilePath(appRoot, targetFile) {
|
|
36
|
+
const appRootAbsolute = path.resolve(String(appRoot || ""));
|
|
37
|
+
if (!appRootAbsolute) {
|
|
38
|
+
throw new Error("crud-server-generator add-field requires appRoot.");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const normalizedTargetFile = normalizeText(targetFile);
|
|
42
|
+
if (!normalizedTargetFile) {
|
|
43
|
+
throw new Error("crud-server-generator add-field requires target file path.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const absolutePath = path.isAbsolute(normalizedTargetFile)
|
|
47
|
+
? path.resolve(normalizedTargetFile)
|
|
48
|
+
: path.resolve(appRootAbsolute, normalizedTargetFile);
|
|
49
|
+
const relativePath = path.relative(appRootAbsolute, absolutePath);
|
|
50
|
+
if (
|
|
51
|
+
!relativePath ||
|
|
52
|
+
relativePath === ".." ||
|
|
53
|
+
relativePath.startsWith(`..${path.sep}`) ||
|
|
54
|
+
path.isAbsolute(relativePath)
|
|
55
|
+
) {
|
|
56
|
+
throw new Error("crud-server-generator add-field target file must stay within app root.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
absolutePath,
|
|
61
|
+
relativePath
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseSubcommandArgs(args = []) {
|
|
66
|
+
const source = Array.isArray(args) ? args : [];
|
|
67
|
+
const fieldKey = toCamelCase(normalizeText(source[0]));
|
|
68
|
+
const targetFile = normalizeText(source[1]);
|
|
69
|
+
|
|
70
|
+
if (!fieldKey) {
|
|
71
|
+
throw new Error("crud-server-generator add-field requires <fieldKey>.");
|
|
72
|
+
}
|
|
73
|
+
if (!targetFile) {
|
|
74
|
+
throw new Error("crud-server-generator add-field requires <targetFile>.");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
fieldKey,
|
|
79
|
+
targetFile
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function resolveRequestedTableConfig(source = "", options = {}, context = "crud-server-generator add-field") {
|
|
84
|
+
const defaults = resolveCrudResourceDefaults(source, context);
|
|
85
|
+
const tableName = normalizeText(options?.["table-name"] || defaults.tableName);
|
|
86
|
+
if (!tableName) {
|
|
87
|
+
throw new Error(`${context} requires --table-name or resource tableName.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const idColumn = normalizeText(options?.["id-column"] || defaults.idColumn) || "id";
|
|
91
|
+
return {
|
|
92
|
+
tableName,
|
|
93
|
+
idColumn
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resolveColumnForField(snapshot = {}, fieldKey = "", { idColumn = "id" } = {}) {
|
|
98
|
+
const key = toCamelCase(normalizeText(fieldKey));
|
|
99
|
+
const scaffoldColumns = resolveScaffoldColumns({
|
|
100
|
+
...snapshot,
|
|
101
|
+
idColumn
|
|
102
|
+
});
|
|
103
|
+
const column = scaffoldColumns.find((entry) => normalizeText(entry?.key) === key) || null;
|
|
104
|
+
if (column) {
|
|
105
|
+
return column;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const available = scaffoldColumns
|
|
109
|
+
.map((column) => normalizeText(column?.key))
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.join(", ");
|
|
112
|
+
throw new Error(
|
|
113
|
+
`crud-server-generator add-field could not find field "${key}" in DB snapshot columns. Available: ${available || "<none>"}.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildFieldMetaEntry(snapshot = {}, column = {}) {
|
|
118
|
+
const entries = buildFieldMetaEntries({
|
|
119
|
+
outputColumns: [column],
|
|
120
|
+
writableColumns: [column],
|
|
121
|
+
snapshot
|
|
122
|
+
});
|
|
123
|
+
const key = normalizeText(column?.key);
|
|
124
|
+
return entries.find((entry) => normalizeText(entry?.key) === key) || null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function collectKnownIdentifiers(expression = "") {
|
|
128
|
+
return new Set(String(expression || "").match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || []);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resolveImportsForField({ inputNormalizationExpression = "", outputNormalizationExpression = "" } = {}) {
|
|
132
|
+
const normalizeImports = new Set(["normalizeIfInSource"]);
|
|
133
|
+
const databaseRuntimeImports = new Set();
|
|
134
|
+
const databaseRuntimeRepositoryImports = new Set();
|
|
135
|
+
|
|
136
|
+
const identifiers = new Set([
|
|
137
|
+
...collectKnownIdentifiers(inputNormalizationExpression),
|
|
138
|
+
...collectKnownIdentifiers(outputNormalizationExpression)
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
for (const identifier of identifiers) {
|
|
142
|
+
if (NORMALIZE_SUPPORT_IMPORTS.has(identifier)) {
|
|
143
|
+
normalizeImports.add(identifier);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (DATABASE_RUNTIME_IMPORTS.has(identifier)) {
|
|
147
|
+
databaseRuntimeImports.add(identifier);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (DATABASE_RUNTIME_REPOSITORY_IMPORTS.has(identifier)) {
|
|
151
|
+
databaseRuntimeRepositoryImports.add(identifier);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
normalizeImports: [...normalizeImports],
|
|
157
|
+
databaseRuntimeImports: [...databaseRuntimeImports],
|
|
158
|
+
databaseRuntimeRepositoryImports: [...databaseRuntimeRepositoryImports]
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function resolveOutputNormalizationExpression(column = {}) {
|
|
163
|
+
const outputNormalizer = renderOutputNormalizerExpression(column);
|
|
164
|
+
const sourceAccess = renderPropertyAccess("source", column.key);
|
|
165
|
+
if (!outputNormalizer) {
|
|
166
|
+
return sourceAccess;
|
|
167
|
+
}
|
|
168
|
+
const wrapper = column?.nullable === true ? "normalizeOrNull" : "normalizeIfPresent";
|
|
169
|
+
return `${wrapper}(${sourceAccess}, ${outputNormalizer})`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function runGeneratorSubcommand({
|
|
173
|
+
appRoot,
|
|
174
|
+
subcommand = "",
|
|
175
|
+
args = [],
|
|
176
|
+
options = {},
|
|
177
|
+
dryRun = false,
|
|
178
|
+
resolveSnapshot = resolveGenerationSnapshot
|
|
179
|
+
} = {}) {
|
|
180
|
+
const normalizedSubcommand = normalizeText(subcommand).toLowerCase();
|
|
181
|
+
if (normalizedSubcommand !== "add-field") {
|
|
182
|
+
throw new Error(`Unsupported crud-server-generator subcommand: ${normalizedSubcommand || "<empty>"}.`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const { fieldKey, targetFile } = parseSubcommandArgs(args);
|
|
186
|
+
const { absolutePath: targetAbsolutePath, relativePath: targetRelativePath } = resolveTargetFilePath(
|
|
187
|
+
appRoot,
|
|
188
|
+
targetFile
|
|
189
|
+
);
|
|
190
|
+
const originalSource = await readFile(targetAbsolutePath, "utf8");
|
|
191
|
+
const { tableName, idColumn } = resolveRequestedTableConfig(originalSource, options);
|
|
192
|
+
const snapshot = await resolveSnapshot({
|
|
193
|
+
appRoot,
|
|
194
|
+
tableName,
|
|
195
|
+
idColumnOption: idColumn
|
|
196
|
+
});
|
|
197
|
+
const column = resolveColumnForField(snapshot, fieldKey, { idColumn });
|
|
198
|
+
if (column?.writable !== true) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`crud-server-generator add-field cannot patch non-writable field "${fieldKey}" (column "${column.name}").`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const createSchemaExpression = renderResourceFieldSchema(column, { forOutput: false });
|
|
205
|
+
const outputSchemaExpression = renderResourceFieldSchema(column, { forOutput: true });
|
|
206
|
+
const inputNormalizationExpression = renderInputNormalizer(column);
|
|
207
|
+
const outputNormalizationExpression = resolveOutputNormalizationExpression(column);
|
|
208
|
+
const fieldMetaEntry = buildFieldMetaEntry(snapshot, column);
|
|
209
|
+
const imports = resolveImportsForField({
|
|
210
|
+
inputNormalizationExpression,
|
|
211
|
+
outputNormalizationExpression
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const applied = applyCrudResourceFieldPatch(originalSource, {
|
|
215
|
+
fieldKey,
|
|
216
|
+
createSchemaExpression,
|
|
217
|
+
outputSchemaExpression,
|
|
218
|
+
inputNormalizationExpression,
|
|
219
|
+
outputNormalizationExpression,
|
|
220
|
+
fieldMetaEntry,
|
|
221
|
+
normalizeImportNames: imports.normalizeImports,
|
|
222
|
+
databaseRuntimeImportNames: imports.databaseRuntimeImports,
|
|
223
|
+
databaseRuntimeRepositoryOptionsImportNames: imports.databaseRuntimeRepositoryImports
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
if (applied.changed && dryRun !== true) {
|
|
227
|
+
await writeFile(targetAbsolutePath, applied.content, "utf8");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
touchedFiles: applied.changed ? [toPosixPath(targetRelativePath)] : [],
|
|
232
|
+
summary: applied.changed
|
|
233
|
+
? `Added field "${fieldKey}" to ${toPosixPath(targetRelativePath)}.`
|
|
234
|
+
: `Field "${fieldKey}" already exists in ${toPosixPath(targetRelativePath)}.`
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export { runGeneratorSubcommand };
|