@lenne.tech/nest-server 11.40.0 → 11.41.1

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 (29) hide show
  1. package/.claude/rules/configurable-features.md +2 -0
  2. package/.claude/rules/testing.md +3 -3
  3. package/FRAMEWORK-API.md +2 -1
  4. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  5. package/dist/core/modules/ai/core-ai.controller.js +6 -0
  6. package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
  7. package/dist/core/modules/ai/interfaces/llm-provider.interface.d.ts +3 -0
  8. package/dist/core/modules/ai/models/core-ai-prompt.model.js +1 -1
  9. package/dist/core/modules/ai/models/core-ai-prompt.model.js.map +1 -1
  10. package/dist/core/modules/ai/models/core-ai-slot.model.js +1 -1
  11. package/dist/core/modules/ai/models/core-ai-slot.model.js.map +1 -1
  12. package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +9 -1
  13. package/dist/core/modules/ai/providers/openai-compatible.provider.js +130 -12
  14. package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
  15. package/dist/core/modules/ai/services/core-ai.service.d.ts +12 -1
  16. package/dist/core/modules/ai/services/core-ai.service.js +131 -13
  17. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  18. package/dist/tsconfig.build.tsbuildinfo +1 -1
  19. package/migration-guides/11.40.0-to-11.41.0.md +118 -0
  20. package/migration-guides/11.41.0-to-11.41.1.md +114 -0
  21. package/package.json +1 -1
  22. package/src/core/common/interfaces/server-options.interface.ts +17 -0
  23. package/src/core/modules/ai/README.md +26 -5
  24. package/src/core/modules/ai/core-ai.controller.ts +16 -0
  25. package/src/core/modules/ai/interfaces/llm-provider.interface.ts +39 -0
  26. package/src/core/modules/ai/models/core-ai-prompt.model.ts +10 -1
  27. package/src/core/modules/ai/models/core-ai-slot.model.ts +10 -1
  28. package/src/core/modules/ai/providers/openai-compatible.provider.ts +380 -17
  29. package/src/core/modules/ai/services/core-ai.service.ts +333 -23
@@ -0,0 +1,118 @@
1
+ # Migration Guide: 11.40.0 → 11.41.0
2
+
3
+ > **Why a MINOR.** The MAJOR digit in this package tracks the NestJS major (11.x = NestJS 11), so it
4
+ > is not ours to spend — every behaviour change of our own ships as a MINOR. This release changes
5
+ > when the AI module asks an endpoint for JSON, which a project can notice.
6
+
7
+ ## Overview
8
+
9
+ | Category | Effort | Applies to |
10
+ |----------|--------|-----------|
11
+ | **Behaviour change** | none | Projects using the AI module — three internal calls stop requesting JSON |
12
+ | Bugfix | none | Everyone using the AI module — capability detection and context windows get more accurate |
13
+ | New feature (opt-in) | none | `ai.maxRunMs` |
14
+ | Internal | none | Nobody — a duplicate index declaration was removed |
15
+
16
+ Most projects update with `pnpm update @lenne.tech/nest-server` and read no further. **Projects that
17
+ do not use the AI module are unaffected by all of it.**
18
+
19
+ ## Quick Migration
20
+
21
+ ```bash
22
+ pnpm update @lenne.tech/nest-server
23
+ ```
24
+
25
+ ```bash
26
+ # Do you use the AI module at all? If this is empty, you are done.
27
+ grep -rn "CoreAiModule\|aiConnections\|AiTool" src/ 2>/dev/null
28
+ ```
29
+
30
+ ## Behaviour change: JSON mode is decided per PROMPT, not per connection
31
+
32
+ ### What changed
33
+
34
+ A connection with `supportsJsonResponse: true` used to send `response_format: json_object` on
35
+ **every** completion. It is now sent only when the call actually wants structured output.
36
+
37
+ That was wrong in both directions, and the second one is why it is worth fixing rather than leaving:
38
+
39
+ - The flag describes what the ENDPOINT can do, not what a given prompt NEEDS. A capability is not
40
+ an instruction.
41
+ - It leaked into calls that must not be JSON. The final answer to the user, and the compaction step
42
+ that summarises a long conversation, both want prose — and both were being asked for JSON. The
43
+ compaction case is the visible one: it spliced JSON-wrapped summaries into the history that the
44
+ model then had to read back.
45
+
46
+ ### Do I need to do anything?
47
+
48
+ **No.** There is deliberately no new option to pass: `jsonResponse` lives on `LlmCompletionOptions`,
49
+ which is the PROVIDER-level contract, and the module sets it for itself. It is not a field on the
50
+ prompt input, and nothing in a consumer project needs to change.
51
+
52
+ Concretely, the narrowing is three call sites, all of which wanted prose and were being asked for
53
+ JSON:
54
+
55
+ | Call | Now |
56
+ |------|-----|
57
+ | The agent loop, on a connection with NATIVE tool calling | JSON mode off — the tools carry the structure |
58
+ | The final answer to the user | never JSON |
59
+ | Compaction (summarising a long conversation) | never JSON |
60
+
61
+ A connection WITHOUT native tool calling is untouched: emulated tool calling is built on
62
+ prompt-driven JSON, so those calls still request it.
63
+
64
+ The one thing to know is the seam the fix deliberately leaves imperfect, because a project can reach
65
+ it: the gate keys on native tool support, not on what the prompt asks for. A project that overrides
66
+ the `native` prompt slot via `CoreAiSlotService` to request JSON anyway will have it narrowed off.
67
+ That degrades gracefully rather than failing — prompt-driven JSON is the fallback the module is
68
+ built around, and its extractor is lenient — but if you have such an override and see prose where
69
+ you expected an object, this is why.
70
+
71
+ A custom `ILlmProvider` needs no change either: `jsonResponse` is OPTIONAL, so an implementation
72
+ that ignores it keeps compiling and keeps its previous behaviour.
73
+
74
+ ## Bugfixes (no action required)
75
+
76
+ | Fix | What it was |
77
+ |-----|-------------|
78
+ | **Context window for two model families** | The known-model table matched by substring, so `ministral` did not match `mistral` and `mistral-medium` had no entry at all. Both fell back to the 8192 default instead of their real 131072 — silently truncating history on models that had 16x the room. Both are now listed |
79
+ | **Capability probes recorded permanent false negatives** | A reasoning model spends output tokens on its thinking phase BEFORE emitting `tool_calls`. With the old few-token probe budget the endpoint answered `200` with `finish_reason: 'length'` and no tool call, which read as "native tools unsupported" — persisted, never re-probed, and the assistant degraded to emulated tool calling for good. The probe now budgets 256 tokens and retries once at 1024 before answering `false` |
80
+ | **A thrown detection re-probed on every prompt** | A detection that threw left the capability `undefined`, and `undefined` is what triggers detection — so a transient endpoint blip fired an extra upstream completion before EVERY user prompt, ahead of the rate limiter and outside budget accounting. A 5-minute per-connection backoff now bounds it |
81
+ | **Duplicate `tenantId` index** | Two AI models declared an index the `mongooseTenantPlugin` already creates, producing Mongoose "Duplicate schema index" warnings. Declaration removed; the index itself is unchanged and created by the plugin as before |
82
+
83
+ ## New: `ai.maxRunMs` (opt-in, off by default)
84
+
85
+ A wall-clock ceiling for ONE prompt run, checked before each agent-loop iteration.
86
+
87
+ ```typescript
88
+ ai: {
89
+ maxRunMs: 120000, // 0 or omitted = no limit (previous behaviour)
90
+ }
91
+ ```
92
+
93
+ **Why it is worth setting.** Without it, a run's only bound is `maxIterations` multiplied by the
94
+ connection's per-call timeout — 8 iterations at the 120 s default is a request that can legitimately
95
+ hold a socket, its message buffer and a request context for 16 minutes, with compaction adding a
96
+ call per iteration on top. Set it to something a client would actually wait for.
97
+
98
+ A misconfigured value degrades to "no limit" rather than to an expired deadline: the check is
99
+ `maxRunMs > 0`, and a non-numeric value arriving through `NSC__AI__MAX_RUN_MS` yields `NaN`, which
100
+ fails that comparison. See `.claude/rules/configurable-features.md` → Numeric Sentinel, Family A.
101
+
102
+ ## Troubleshooting
103
+
104
+ **"My final answer used to come back as JSON and now returns prose."** That is the behaviour change
105
+ above, and it is the fix rather than a regression: the final answer was never meant to be JSON. If
106
+ you were parsing it, parse the prose or move the structured part into a tool result, which is what
107
+ tool calling is for.
108
+
109
+ **"An endpoint that supported tools is suddenly using emulated tool calling."** That is the OLD
110
+ defect, and it persisted the wrong flag. Clear the stored capability on the connection so it is
111
+ re-probed with the new budget; detection now records `true` where it previously recorded a false
112
+ negative.
113
+
114
+ ## Module Documentation
115
+
116
+ - [AI module README](../src/core/modules/ai/README.md)
117
+ - [AI integration checklist](../src/core/modules/ai/INTEGRATION-CHECKLIST.md)
118
+ - [Configurable features](../.claude/rules/configurable-features.md)
@@ -0,0 +1,114 @@
1
+ # Migration Guide: 11.41.0 → 11.41.1
2
+
3
+ ## Overview
4
+
5
+ | Category | Effort | Applies to |
6
+ |----------|--------|-----------|
7
+ | **Bugfix** | none | Projects whose AI connection points at a reasoning model — prompts that silently returned nothing now return an answer |
8
+ | Behaviour change | none, but worth knowing | Projects using `ai.budget` — one prompt can now cost two upstream calls, and both are metered |
9
+ | New (read-only) | none | `LlmResponse.finishReason`, `LlmUsage.reasoningTokens` — both optional |
10
+
11
+ Most projects update with `pnpm update @lenne.tech/nest-server` and read no further. **Projects that
12
+ do not use the AI module are unaffected by all of it.**
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ pnpm update @lenne.tech/nest-server
18
+ ```
19
+
20
+ ```bash
21
+ # Do you use the AI module at all? If this is empty, you are done.
22
+ grep -rn "CoreAiModule\|aiConnections\|AiTool" src/ 2>/dev/null
23
+ ```
24
+
25
+ ## Bugfix: a reasoning model that thinks past its budget now gets a second chance
26
+
27
+ ### What was broken
28
+
29
+ A reasoning model spends output tokens on its thinking phase **before** it writes a single character
30
+ of the answer. When that phase consumes the whole `max_tokens` allowance, the endpoint answers a
31
+ perfectly ordinary `HTTP 200` carrying `finish_reason: 'length'`, empty content, and
32
+ `reasoning_tokens == completion_tokens`.
33
+
34
+ `chat()` handed that on as an empty string. Nothing distinguished it from "the model had nothing to
35
+ say", so a consumer with a modest `maxTokens` received nothing — silently, on every prompt, for as
36
+ long as the connection pointed at such a model. Call sites that degrade to a null-fallback reported
37
+ no error at all.
38
+
39
+ Measured against an OpenAI-compatible hosting endpoint with a 900-token budget:
40
+
41
+ | Model | Default | With the thinking phase off |
42
+ |-------|---------|-----------------------------|
43
+ | Mistral-Medium-3.5-128B | empty, 900/900 spent thinking | 348 characters, 0.8 s |
44
+ | Qwen3.6-35B-A3B-FP8 | empty, 900/900 spent thinking | 399 characters, 0.8 s |
45
+ | gpt-oss-120b | 537 characters | HTTP 400 — rejects the parameter |
46
+ | Ministral-3-14B-Instruct | 1213 characters | 901 characters |
47
+
48
+ **Raising the token budget does not help.** The model spends whatever it is given: 900 of 900, 1500
49
+ of 1500, 2048 of 2048, 4096 of 4096 — always with empty content.
50
+
51
+ ### What happens now
52
+
53
+ When, and only when, a completion comes back with `finish_reason: 'length'`, no content and no tool
54
+ call, the provider retries the identical request once with `reasoning_effort: 'none'`. Everything
55
+ else is unchanged: a truncated answer that HAS content is kept (retrying would discard usable text),
56
+ an empty answer with `finish_reason: 'stop'` is kept (the model chose to say nothing, so no budget
57
+ ran out), and a tool call is kept (the model did answer, in the tool channel).
58
+
59
+ The parameter cannot be sent pre-emptively — `gpt-oss-120b` rejects it with a `400` while working
60
+ perfectly well without it. When the retry fails for any reason, the original response is returned
61
+ with its `finishReason` and usage intact, and the log names the actual failure.
62
+
63
+ ### Do I need to do anything?
64
+
65
+ **No.** There is no new configuration, and deliberately so: the behaviour only triggers on a
66
+ response that previously reached you as an empty string, which is not an outcome anyone can want.
67
+
68
+ ## Behaviour change: one prompt can now cost two upstream calls
69
+
70
+ Worth knowing if you run with `ai.budget` limits or watch provider spend.
71
+
72
+ On the retry path both calls are genuinely billed by the provider, so **both are reported in
73
+ `usage`** — `promptTokens`, `completionTokens` and `reasoningTokens` are the sum of the two. That is
74
+ what keeps `ai.budget` honest: the accounting sums `totalTokens` from the audit records, so
75
+ reporting only the retry would hide the starved call, which by definition burned the entire
76
+ `max_tokens` allowance, from the very limit meant to bound it.
77
+
78
+ Two consequences:
79
+
80
+ - On a model that starves on every prompt, real token spend against a configured limit is roughly
81
+ double what the same workload cost before — because it always was, and is now measured. If you
82
+ set `ai.budget.user.maxTokens` against observed usage from 11.41.0, re-check the number.
83
+ - `contextWindow.used` counts the prompt twice on that path, since the same prompt really was sent
84
+ twice. It is clamped to the window size, so it can saturate at 100 % but never report nonsense.
85
+
86
+ The retry shares the original call's timeout budget rather than starting a fresh one, so the
87
+ documented ceiling of `maxIterations` × the per-call timeout still holds. A connection whose entire
88
+ `timeoutMs` is below one second never retries.
89
+
90
+ ## New: two optional fields on the provider contract
91
+
92
+ ```ts
93
+ interface LlmResponse {
94
+ /** 'stop', 'length', 'tool_calls', … — undefined when the backend omits it. */
95
+ finishReason?: string;
96
+ }
97
+
98
+ interface LlmUsage {
99
+ /** Output tokens spent thinking. Part of completionTokens, not additional to it. */
100
+ reasoningTokens?: number;
101
+ }
102
+ ```
103
+
104
+ Both are optional additions, so every existing `ILlmProvider` implementation and every `LlmResponse`
105
+ literal still compiles unchanged. They exist because without them a caller cannot tell an answer
106
+ apart from a fragment: `length` means the budget ran out mid-flight, so short or empty text is a
107
+ truncation rather than the model's verdict.
108
+
109
+ ## Under the hood
110
+
111
+ `OpenAiCompatibleProvider` gained two protected seams a subclass can override: `postCompletion()`
112
+ (one request, mapped to `LlmResponse`) and `isReasoningStarved()` (the detection predicate). The SSRF
113
+ egress allowlist (`ai.allowedBaseUrlHosts`) is applied before both calls, as before — the retry
114
+ reuses the already-validated URL and changes only the request body.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.40.0",
3
+ "version": "11.41.1",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -1901,6 +1901,23 @@ export interface IAi {
1901
1901
  /** Maximum number of agent-loop iterations (tool round-trips). @default 5 */
1902
1902
  maxIterations?: number;
1903
1903
 
1904
+ /**
1905
+ * Wall-clock ceiling for ONE prompt run, in milliseconds. Checked before each
1906
+ * agent-loop iteration; once exceeded the run stops and answers with whatever it
1907
+ * has (or the translated "no final answer" message).
1908
+ *
1909
+ * Without it the only bound is `maxIterations` multiplied by the connection's
1910
+ * PER-CALL timeout — e.g. 8 iterations at the 120 s default is a request that can
1911
+ * legitimately occupy a socket, its message buffer and a request context for 16
1912
+ * minutes, and compaction can add a further call per iteration on top. Set this
1913
+ * to something a client would actually wait for.
1914
+ *
1915
+ * `0` or omitted disables the check (previous behaviour).
1916
+ *
1917
+ * @default 0
1918
+ */
1919
+ maxRunMs?: number;
1920
+
1904
1921
  /** Maximum characters of a tool-results payload fed back to the model. @default 12000 */
1905
1922
  maxToolResultChars?: number;
1906
1923
 
@@ -118,11 +118,32 @@ never probed). Detection runs in two complementary ways:
118
118
  warns — the stored value is never changed. OFF by default because it makes outbound calls to
119
119
  the LLM endpoints on every boot; also skipped in the ci/e2e runners.
120
120
 
121
- The probe is provider-agnostic best effort: `response_format: json_object` is sent
122
- (2xx JSON supported); a trivial tool with `tool_choice: 'required'` is sent (2xx
123
- returning a `tool_calls` result → native tools supported; a `4xx` or a silent ignore
124
- → unsupported). Override `OpenAiCompatibleProvider.detectCapabilities()` for custom
125
- backends, or implement the optional `ILlmProvider.detectCapabilities()` in your own provider.
121
+ The probe is provider-agnostic best effort, and a 2xx alone is never the verdict —
122
+ a backend that does not implement a parameter typically ignores it and answers
123
+ normally, which would persist a `true` it never earns:
124
+
125
+ - **JSON:** `response_format: json_object` is sent and the CONTENT must actually
126
+ parse. A response truncated by the output budget (`finish_reason: 'length'`, empty
127
+ OR partial) proves nothing and is retried once with a larger budget before the
128
+ probe settles on `false`.
129
+ - **Native tools:** a trivial tool with `tool_choice: 'required'` is sent; a
130
+ `tool_calls` result → supported, a `4xx` or a complete answer without tool calls →
131
+ unsupported, a truncation → the same one retry.
132
+
133
+ Both probes run concurrently. Override `OpenAiCompatibleProvider.detectCapabilities()`
134
+ for custom backends, or implement the optional `ILlmProvider.detectCapabilities()` in
135
+ your own provider.
136
+
137
+ > **`supportsJsonResponse` is a CONNECTION flag, but whether an answer must be JSON is
138
+ > a property of the PROMPT.** The JSON output contract is carried only by the
139
+ > `output_contract` / `tool_protocol_emulated` fragments (both `capability: 'emulated'`)
140
+ > and by `plan_protocol`. A **native**-tools run receives none of them and is asked for
141
+ > prose — attaching `response_format` on top is a contradiction the model can only
142
+ > resolve by inventing a shape of its own, which then reaches the user as the answer.
143
+ > The orchestrator therefore decides JSON mode per CALL, not per connection: pass
144
+ > `jsonResponse: false` in `LlmCompletionOptions` from any call whose prompt asks for
145
+ > prose. The option only ever NARROWS — it can never assert JSON mode for a connection
146
+ > whose endpoint was not probed for it.
126
147
 
127
148
  ### Backend examples (external, local, CLI)
128
149
 
@@ -96,7 +96,22 @@ export class CoreAiController {
96
96
  res.setHeader('Cache-Control', 'no-cache');
97
97
  res.setHeader('Connection', 'keep-alive');
98
98
  res.setHeader('Content-Type', 'text/event-stream');
99
+ // Disable proxy-side response buffering (nginx and friends honour this); without
100
+ // it an intermediary can hold the events until the response ends, which defeats
101
+ // the whole point of the stream.
102
+ res.setHeader('X-Accel-Buffering', 'no');
99
103
  res.flushHeaders?.();
104
+
105
+ // A prompt run can stay silent for a long time — a multi-step turn measured
106
+ // 30-40 s, and `ai.maxRunMs` allows up to two minutes. Many proxies close an
107
+ // idle connection at 60 s, which the client then sees as a turn that silently
108
+ // vanished. Comment frames are ignored by every SSE client and keep the
109
+ // connection observably alive.
110
+ const heartbeat = setInterval(() => {
111
+ res.write(': keep-alive\n\n');
112
+ }, 15_000);
113
+ heartbeat.unref?.();
114
+
100
115
  try {
101
116
  for await (const event of this.aiService.promptStream(input, serviceOptions)) {
102
117
  res.write(`data: ${JSON.stringify(event)}\n\n`);
@@ -104,6 +119,7 @@ export class CoreAiController {
104
119
  } catch (err) {
105
120
  res.write(`data: ${JSON.stringify({ message: (err as Error).message, type: 'error' })}\n\n`);
106
121
  } finally {
122
+ clearInterval(heartbeat);
107
123
  res.end();
108
124
  }
109
125
  }
@@ -100,6 +100,17 @@ export interface LlmToolCall {
100
100
  export interface LlmUsage {
101
101
  completionTokens?: number;
102
102
  promptTokens?: number;
103
+ /**
104
+ * Output tokens the model spent THINKING before answering, where the backend
105
+ * reports them (`completion_tokens_details.reasoning_tokens`). Part of
106
+ * {@link completionTokens}, not additional to it.
107
+ *
108
+ * Worth surfacing because the thinking phase competes with the answer for the
109
+ * SAME budget: when it equals `completionTokens` the model never got to the
110
+ * answer, which is a very different failure from a model that had nothing to
111
+ * say. See {@link LlmResponse.finishReason}.
112
+ */
113
+ reasoningTokens?: number;
103
114
  totalTokens?: number;
104
115
  }
105
116
 
@@ -108,6 +119,21 @@ export interface LlmUsage {
108
119
  * when omitted.
109
120
  */
110
121
  export interface LlmCompletionOptions {
122
+ /**
123
+ * Set `false` to suppress structured-JSON mode for THIS call even though the
124
+ * connection advertises `supportsJsonResponse`.
125
+ *
126
+ * Narrowing only — it can never switch JSON mode ON for a connection whose
127
+ * endpoint was not probed for it, because the flag is measured per connection and
128
+ * asserting it elsewhere is what produces a 4xx nobody expected.
129
+ *
130
+ * It exists because `supportsJsonResponse` is CONNECTION state while whether an
131
+ * answer must be JSON is a property of the PROMPT. A caller that asks for prose —
132
+ * a summary, a native-tools chat turn, a plan summary — must be able to say so
133
+ * without rebuilding the connection object around the flag.
134
+ */
135
+ jsonResponse?: boolean;
136
+
111
137
  /** Maximum number of tokens to generate. */
112
138
  maxTokens?: number;
113
139
 
@@ -125,6 +151,19 @@ export interface LlmCompletionOptions {
125
151
  * Normalized response of a single LLM completion.
126
152
  */
127
153
  export interface LlmResponse {
154
+ /**
155
+ * Why the model stopped, as reported by the backend (`stop`, `length`,
156
+ * `tool_calls`, …). Undefined when the backend omits it.
157
+ *
158
+ * Without this a caller cannot tell an answer apart from a fragment: `length`
159
+ * means the output budget ran out mid-flight, so short or empty text is a
160
+ * truncation and not the model's verdict. Retrying an identical request is
161
+ * pointless in that case — and actively misleading against a backend that
162
+ * caches identical prompts, which answers the retry from cache in
163
+ * milliseconds.
164
+ */
165
+ finishReason?: string;
166
+
128
167
  /** Raw provider payload (for debugging/audit, never sent to clients). */
129
168
  raw?: unknown;
130
169
 
@@ -89,9 +89,18 @@ export class CoreAiPrompt extends CorePersistenceModel {
89
89
 
90
90
  /** Tenant id when scope = 'tenant' (set from the creator's tenant at create time). */
91
91
  @UnifiedField({
92
+ // No `index: true`: declaring the `tenantId` PATH is what activates
93
+ // `mongooseTenantPlugin`, and the plugin then adds `schema.index({ tenantId: 1 })`
94
+ // itself. Declaring it here as well makes Mongoose log
95
+ // "Duplicate schema index on {"tenantId":1}" on every boot.
96
+ //
97
+ // The `mongoose` key itself MUST stay: `UnifiedField` emits `@Prop` only inside
98
+ // `if (opts.mongoose)`, so dropping the whole key would remove the schema path —
99
+ // and `mongooseTenantPlugin` returns early on `!schema.path('tenantId')`, i.e.
100
+ // the model would lose its tenant filtering entirely.
92
101
  description: 'Tenant id (when scope = "tenant")',
93
102
  isOptional: true,
94
- mongoose: { index: true },
103
+ mongoose: { type: String },
95
104
  roles: RoleEnum.S_USER,
96
105
  })
97
106
  tenantId?: string = undefined;
@@ -133,9 +133,18 @@ export class CoreAiSlot extends CorePersistenceModel {
133
133
  * slot is effectively system-wide.
134
134
  */
135
135
  @UnifiedField({
136
+ // No `index: true`: declaring the `tenantId` PATH is what activates
137
+ // `mongooseTenantPlugin`, and the plugin then adds `schema.index({ tenantId: 1 })`
138
+ // itself. Declaring it here as well makes Mongoose log
139
+ // "Duplicate schema index on {"tenantId":1}" on every boot.
140
+ //
141
+ // The `mongoose` key itself MUST stay: `UnifiedField` emits `@Prop` only inside
142
+ // `if (opts.mongoose)`, so dropping the whole key would remove the schema path —
143
+ // and `mongooseTenantPlugin` returns early on `!schema.path('tenantId')`, i.e.
144
+ // the model would lose its tenant filtering entirely.
136
145
  description: 'Tenant id the slot applies to (auto-set; undefined = system-wide)',
137
146
  isOptional: true,
138
- mongoose: { index: true },
147
+ mongoose: { type: String },
139
148
  roles: RoleEnum.ADMIN,
140
149
  })
141
150
  tenantId?: string = undefined;