@dudousxd/nestjs-codegen 0.14.1 → 0.15.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # @dudousxd/nestjs-codegen
2
2
 
3
+ ## 0.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 20db5c0: Emit `filterFields` as a runtime `as const` array on each filter leaf, alongside the existing type-level union, plus an `isFilterField` type guard exported from the generated `api.ts`. Previously the filterable field set existed only as a type, so a field name arriving as a plain `string` from runtime state (a saved view, a user-picked column) could not be passed to `filterQuery().where()` without a cast. Now `api.route.leaf().filterFields` is a `readonly [...] as const` value and `isFilterField(leaf.filterFields, value)` narrows an arbitrary string to the field union, so dynamic field names validate at runtime instead of being asserted with `as`. The runtime array is generated from the same discovered field list as the type-level union (single source in the emitter), so the value can never drift from the type. Purely additive — the guard is emitted only when a route carries filter fields, and leaves without a filter gain no new member.
8
+ - 9b5298b: Add a `@QueryList()` param decorator and a `toStringList` normalizer to the `/nest` subpath for receiving array query params safely. Express (and Nest's default query parser) returns a bare `string` for a single-value query param (`?ids=a`) and a `string[]` only for two or more (`?ids=a&ids=b`), so `ParseArrayPipe` 400s the common single-select case. `@QueryList('ids')` normalizes `string | string[] | comma-joined string | undefined` into a clean `string[]` (`['a']`, `['a','b']`, `[]`), and `toStringList` is exported for the equivalent `class-transformer` `@Transform` on a DTO field. Pairs with the client's `arrayFormat` option: once the client sends `arrayFormat: 'repeat'`, the comma-split becomes a no-op fallback that still covers hand-rolled and `curl` callers. Documented under a new "Receiving array query params" docs page.
9
+
10
+ ## 0.14.2
11
+
12
+ ### Patch Changes
13
+
14
+ - 79d5e73: Fix a drift-guard false positive that permanently blocked incremental regeneration for shared configs: the config hash folded functions in via `toString()`, but the same shared config object yields different function source text per entry point (the CLI loads TS via Node's type stripping; the Nest module runs tsc/SWC-compiled dist), so a genuinely-shared config was flagged as drifted the moment both entry points touched the same outDir. Functions now hash by name only — every setting that can actually diverge is plain data and is still hashed in full. The drift error also now NAMES the top-level keys that differ (via new per-key hashes recorded in the manifest as `configKeyHashes`) instead of a generic example.
15
+
3
16
  ## 0.14.1
4
17
 
5
18
  ### Patch Changes
package/dist/cli/main.cjs CHANGED
@@ -775,6 +775,9 @@ function buildErrorType(c) {
775
775
  }
776
776
  return c.contractSource.error ?? "unknown";
777
777
  }
778
+ function filterFieldLiterals(fields) {
779
+ return fields?.length ? fields.map((f) => JSON.stringify(f)) : [];
780
+ }
778
781
  function emitRouterTypeBlock(tree, indent, outDir, serialization) {
779
782
  const pad = " ".repeat(indent);
780
783
  const lines = [];
@@ -801,7 +804,8 @@ function emitRouterTypeBlock(tree, indent, outDir, serialization) {
801
804
  const params = buildParamsType(c.params);
802
805
  const safeMethod = JSON.stringify(method);
803
806
  const safeUrl = JSON.stringify(c.path);
804
- const filterFields = c.contractSource.filterFields?.length ? c.contractSource.filterFields.map((f) => JSON.stringify(f)).join(" | ") : "never";
807
+ const filterLiterals = filterFieldLiterals(c.contractSource.filterFields);
808
+ const filterFields = filterLiterals.length ? filterLiterals.join(" | ") : "never";
805
809
  const stream = c.contractSource.stream ? "true" : "false";
806
810
  const binary = c.contractSource.binaryResponse ? "true" : "false";
807
811
  lines.push(
@@ -841,6 +845,7 @@ function buildRequestModel(c) {
841
845
  const TA = buildRouterTypeAccess(c.name);
842
846
  const withParams = hasPathParams(c.params);
843
847
  const { isGet, isQuery, hasBody, hasQuery } = requestShape(c.route);
848
+ const filterLiterals = filterFieldLiterals(c.contractSource.filterFields);
844
849
  const fields = [];
845
850
  if (withParams) fields.push(`params: ${TA}['params']`);
846
851
  if (hasQuery) fields.push(`query?: ${TA}['query']`);
@@ -870,7 +875,12 @@ function buildRequestModel(c) {
870
875
  // (`[name]` rather than `[name, undefined]`) so the bare `.queryKey()` is a
871
876
  // clean prefix that partial-matches every parametrized variant — making it
872
877
  // directly usable for `invalidateQueries`.
873
- queryKeyExpr: `(input === undefined ? [${flat}] as const : [${flat}, input] as const)`
878
+ queryKeyExpr: `(input === undefined ? [${flat}] as const : [${flat}, input] as const)`,
879
+ // Runtime counterpart to the type-level `filterFields` union: the same
880
+ // discovered field list, emitted as a literal `[...] as const` so apps can
881
+ // validate a dynamic/user-supplied field string with `isFilterField(...)`
882
+ // instead of casting. Omitted for routes with no filter.
883
+ ...filterLiterals.length ? { filterFieldsExpr: `[${filterLiterals.join(", ")}] as const` } : {}
874
884
  };
875
885
  }
876
886
  function renderFetcherRequest(req, binaryResponse) {
@@ -905,9 +915,24 @@ function emitReqHelper() {
905
915
  ""
906
916
  ];
907
917
  }
918
+ function emitFilterFieldGuard() {
919
+ return [
920
+ "/** Runtime guard: narrows `value` to one of the leaf's `filterFields` (a `readonly K[] as const`), so a dynamic field string can be passed to `.where()` without a cast. */",
921
+ "export function isFilterField<const K extends string>(",
922
+ " fields: readonly K[],",
923
+ " value: string,",
924
+ "): value is K {",
925
+ " return (fields as readonly string[]).includes(value);",
926
+ "}",
927
+ ""
928
+ ];
929
+ }
908
930
  function renderLeaf(pad, objKey, req, requestExpr, members, streamExpr) {
909
931
  const lines = [`${pad}${objKey}: (input?: ${req.inputType}) => ({`];
910
932
  lines.push(`${pad} ...__req<${req.responseType}>(() => ${requestExpr}),`);
933
+ if (req.filterFieldsExpr) {
934
+ lines.push(`${pad} filterFields: ${req.filterFieldsExpr},`);
935
+ }
911
936
  if (streamExpr) {
912
937
  lines.push(`${pad} stream: () => ${streamExpr},`);
913
938
  }
@@ -1179,6 +1204,9 @@ function buildApiFile(routes, outDir, opts = {}) {
1179
1204
  lines.push("};");
1180
1205
  lines.push("");
1181
1206
  lines.push(...emitReqHelper());
1207
+ if (contracted.some((r) => r.contract?.contractSource.filterFields?.length)) {
1208
+ lines.push(...emitFilterFieldGuard());
1209
+ }
1182
1210
  lines.push("export function createApi(fetcher: Fetcher) {");
1183
1211
  lines.push(" return {");
1184
1212
  lines.push(
@@ -2185,19 +2213,41 @@ function isManifestShape(value) {
2185
2213
  if (typeof candidate.hash !== "string") return false;
2186
2214
  if (candidate.entryPoint !== void 0 && !isEntryPoint(candidate.entryPoint)) return false;
2187
2215
  if (candidate.configHash !== void 0 && typeof candidate.configHash !== "string") return false;
2216
+ if (candidate.configKeyHashes !== void 0 && !isStringRecord(candidate.configKeyHashes)) {
2217
+ return false;
2218
+ }
2188
2219
  if (!Array.isArray(candidate.files)) return false;
2189
2220
  return candidate.files.every((entry) => typeof entry === "string");
2190
2221
  }
2222
+ function isStringRecord(value) {
2223
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
2224
+ return Object.values(value).every((entry) => typeof entry === "string");
2225
+ }
2191
2226
  function serializeConfig(config) {
2227
+ return serializeConfigValue(config, `unserializable:${config.codegen.outDir}`);
2228
+ }
2229
+ function serializeConfigValue(value, unserializableMarker) {
2192
2230
  try {
2193
- return JSON.stringify(config, (_key, value) => {
2194
- if (typeof value === "function") return `[fn:${value.name}]${value.toString()}`;
2195
- return value;
2231
+ return JSON.stringify(value, (_key, entry) => {
2232
+ if (typeof entry === "function") return `[fn:${entry.name}]`;
2233
+ return entry;
2196
2234
  });
2197
2235
  } catch {
2198
- return `unserializable:${config.codegen.outDir}:${config.contracts.glob}`;
2236
+ return unserializableMarker;
2199
2237
  }
2200
2238
  }
2239
+ function computeConfigKeyHashes(config) {
2240
+ const hashes = {};
2241
+ for (const [key, value] of Object.entries(config)) {
2242
+ if (value === void 0) continue;
2243
+ hashes[key] = (0, import_node_crypto.createHash)("sha256").update(serializeConfigValue(value, `unserializable:${key}`)).digest("hex");
2244
+ }
2245
+ return hashes;
2246
+ }
2247
+ function diffConfigKeyHashes(previous, current) {
2248
+ const keys = /* @__PURE__ */ new Set([...Object.keys(previous), ...Object.keys(current)]);
2249
+ return [...keys].filter((key) => previous[key] !== current[key]).sort();
2250
+ }
2201
2251
  async function discoverInputFiles(config) {
2202
2252
  const globs = [config.contracts.glob, config.forms.watch];
2203
2253
  if (config.pages) globs.push(config.pages.glob);
@@ -2232,6 +2282,7 @@ async function readManifest(outDir) {
2232
2282
  hash: parsed.hash,
2233
2283
  ...parsed.entryPoint ? { entryPoint: parsed.entryPoint } : {},
2234
2284
  ...parsed.configHash ? { configHash: parsed.configHash } : {},
2285
+ ...parsed.configKeyHashes ? { configKeyHashes: parsed.configKeyHashes } : {},
2235
2286
  files: parsed.files
2236
2287
  };
2237
2288
  } catch {
@@ -2285,8 +2336,9 @@ function debugWarn(message) {
2285
2336
  }
2286
2337
 
2287
2338
  // src/generate.ts
2288
- function driftGuardMessage(outDir, previousEntryPoint, currentEntryPoint) {
2289
- return `[nestjs-codegen] Config drift detected in "${outDir}": the last generate ran from the "${previousEntryPoint}" entry point, this run is from the "${currentEntryPoint}" entry point, and their resolved configs differ (e.g. \`serialization: "json"\` vs \`"superjson"\`). Both entry points must read the SAME config \u2014 export a shared config object (e.g. codegen.config.ts) and import it from BOTH nestjs-codegen.config.ts (CLI) and NestjsCodegenModule.forRoot() (Nest module), or set \`driftGuard: false\` on either config to opt out of this check.`;
2339
+ function driftGuardMessage(outDir, previousEntryPoint, currentEntryPoint, differingKeys) {
2340
+ const differ = differingKeys.length > 0 ? `their resolved configs differ at: ${differingKeys.map((key) => `\`${key}\``).join(", ")}` : "their resolved configs differ (re-run after this generate records per-key hashes to see which keys)";
2341
+ return `[nestjs-codegen] Config drift detected in "${outDir}": the last generate ran from the "${previousEntryPoint}" entry point, this run is from the "${currentEntryPoint}" entry point, and ${differ}. Both entry points must read the SAME config \u2014 export a shared config object (e.g. codegen.config.ts) and import it from BOTH nestjs-codegen.config.ts (CLI) and NestjsCodegenModule.forRoot() (Nest module), or set \`driftGuard: false\` on either config to opt out of this check.`;
2290
2342
  }
2291
2343
  async function generate(config, inputRoutes = [], entryPoint = "cli") {
2292
2344
  setCodegenDebug(config.debug);
@@ -2297,9 +2349,17 @@ async function generate(config, inputRoutes = [], entryPoint = "cli") {
2297
2349
  return;
2298
2350
  }
2299
2351
  const configHash = computeConfigHash(config);
2352
+ const configKeyHashes = computeConfigKeyHashes(config);
2300
2353
  if (config.driftGuard && manifest?.entryPoint && manifest.entryPoint !== entryPoint && manifest.configHash && manifest.configHash !== configHash) {
2301
2354
  throw new DriftGuardError(
2302
- driftGuardMessage(config.codegen.outDir, manifest.entryPoint, entryPoint)
2355
+ driftGuardMessage(
2356
+ config.codegen.outDir,
2357
+ manifest.entryPoint,
2358
+ entryPoint,
2359
+ // A pre-key-hash manifest can't tell us WHICH keys differ — pass none
2360
+ // rather than diffing against {} (which would name every key).
2361
+ manifest.configKeyHashes ? diffConfigKeyHashes(manifest.configKeyHashes, configKeyHashes) : []
2362
+ )
2303
2363
  );
2304
2364
  }
2305
2365
  const extensions = config.extensions ?? [];
@@ -2369,6 +2429,7 @@ async function generate(config, inputRoutes = [], entryPoint = "cli") {
2369
2429
  hash: inputsHash,
2370
2430
  entryPoint,
2371
2431
  configHash,
2432
+ configKeyHashes,
2372
2433
  files: outputFiles
2373
2434
  });
2374
2435
  }
@@ -4801,7 +4862,7 @@ async function watch(config, onChange, options = {}) {
4801
4862
  }
4802
4863
 
4803
4864
  // src/index.ts
4804
- var VERSION = "0.14.1";
4865
+ var VERSION = "0.15.0";
4805
4866
 
4806
4867
  // src/cli/codegen.ts
4807
4868
  async function runCodegen(opts = {}) {