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