@lenne.tech/nest-server 11.31.0 → 11.31.2

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 (31) hide show
  1. package/CLAUDE.md +2 -0
  2. package/FRAMEWORK-API.md +1 -1
  3. package/dist/core/modules/ai/core-ai.controller.d.ts +1 -1
  4. package/dist/core/modules/ai/core-ai.controller.js +4 -5
  5. package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
  6. package/dist/core/modules/ai/core-ai.resolver.d.ts +1 -1
  7. package/dist/core/modules/ai/core-ai.resolver.js +7 -6
  8. package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
  9. package/dist/core/modules/ai/models/core-ai-conversation.model.d.ts +6 -0
  10. package/dist/core/modules/ai/models/core-ai-conversation.model.js +13 -1
  11. package/dist/core/modules/ai/models/core-ai-conversation.model.js.map +1 -1
  12. package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +8 -2
  13. package/dist/core/modules/ai/services/core-ai-conversation.service.js +53 -2
  14. package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
  15. package/dist/core/modules/migrate/cli/migrate-cli.d.ts +13 -1
  16. package/dist/core/modules/migrate/cli/migrate-cli.js +16 -1
  17. package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
  18. package/dist/core/modules/migrate/migration-runner.d.ts +4 -0
  19. package/dist/core/modules/migrate/migration-runner.js +46 -6
  20. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  21. package/dist/tsconfig.build.tsbuildinfo +1 -1
  22. package/migration-guides/11.31.0-to-11.31.1.md +89 -0
  23. package/migration-guides/11.31.1-to-11.31.2.md +127 -0
  24. package/package.json +1 -1
  25. package/src/core/modules/ai/core-ai.controller.ts +12 -9
  26. package/src/core/modules/ai/core-ai.resolver.ts +14 -9
  27. package/src/core/modules/ai/models/core-ai-conversation.model.ts +18 -1
  28. package/src/core/modules/ai/services/core-ai-conversation.service.ts +110 -4
  29. package/src/core/modules/migrate/README.md +56 -0
  30. package/src/core/modules/migrate/cli/migrate-cli.ts +27 -3
  31. package/src/core/modules/migrate/migration-runner.ts +123 -6
@@ -0,0 +1,89 @@
1
+ # Migration Guide: 11.31.0 → 11.31.1
2
+
3
+ > Note: written on the feature branch (`feature/migrate-safe-ts-js-transition`).
4
+ > If the release lands under a different version number, rename this file accordingly.
5
+
6
+ ## Overview
7
+
8
+ | Category | Details |
9
+ |----------|---------|
10
+ | **Breaking Changes** | None |
11
+ | **New Features** | Extension-agnostic migration identity (safe ts-node → compiled-JS transition); strict integrity mode (`--strict` / `NSC__MIGRATE__STRICT` / `MigrationRunnerOptions.strict`); `status()` reports `missing` files; `migrate list` annotates missing files |
12
+ | **Bugfixes** | Co-present `foo.ts` + `foo.js` no longer execute twice in one `up()` run (deduplicated by identity, `.js` wins) |
13
+ | **Migration Effort** | None — no code changes required. Optional: enable strict mode in production images |
14
+
15
+ ---
16
+
17
+ ## Quick Migration
18
+
19
+ ```bash
20
+ # Update package
21
+ npm install @lenne.tech/nest-server@11.31.1
22
+
23
+ # Verify build
24
+ npm run build
25
+
26
+ # Run tests
27
+ npm test
28
+ ```
29
+
30
+ No code changes required in any project.
31
+
32
+ ---
33
+
34
+ ## What's New in 11.31.1
35
+
36
+ ### Extension-agnostic migration identity (safe ts-node → compiled-JS transition)
37
+
38
+ `migrate up`, `migrate down`, and `migrate list` now compare migrations by their
39
+ timestamped file stem WITHOUT the `.ts`/`.js` extension. A migration recorded as
40
+ `1699-foo.ts` (run via ts-node) matches the compiled `1699-foo.js` in a production
41
+ image — already-applied migrations are no longer treated as "pending" and re-run
42
+ after switching the image to compiled JavaScript. No state rewrite is needed;
43
+ existing databases keep working as-is.
44
+
45
+ If both `foo.ts` and `foo.js` are present in the migrations directory (e.g. an
46
+ overlapping `outDir`), they now count as ONE migration: only the `.js` file is
47
+ loaded and executed, with a warning. Previously both files would have executed.
48
+
49
+ Note: the legacy `synchronizedUp()` helper drives the external `migrate` package's
50
+ own state handling and keeps raw-filename identity — the new semantics apply to the
51
+ `MigrationRunner`/built-in CLI path only.
52
+
53
+ ### Missing migration files tolerated by default (up/list)
54
+
55
+ Old, already-applied migration files can be deleted (they live in git and can be
56
+ restored). `migrate up` no longer needs their files to be present:
57
+
58
+ - Default: `up` warns (`[migrate] N recorded migration file(s) missing — tolerated`) and continues, so the server still boots. Previously such entries were silently ignored — log-scraping setups will see a NEW warning.
59
+ - `migrate list` marks affected entries with `(file missing)` and `status()` additionally returns them in a new `missing: string[]` field (additive, backward compatible).
60
+ - `migrate down` ALWAYS fails hard when the rollback file is missing — unchanged from 11.31.0, with a clearer message (`… restore the file from git to roll back.`). Rollback is an explicit operator action, never a boot path.
61
+
62
+ ### Strict integrity mode (opt-in)
63
+
64
+ Turn the tolerated drift into a hard error (`up` throws, `list` exits non-zero):
65
+
66
+ | Activation path | Scope |
67
+ |-----------------|-------|
68
+ | `--strict` CLI flag | single invocation |
69
+ | `NSC__MIGRATE__STRICT=1\|true\|yes` env var (case-insensitive) | CLI **and** programmatic `MigrationRunner` instances |
70
+ | `new MigrationRunner({ strict: true, ... })` | programmatic |
71
+
72
+ **Recommended for production images:** set `NSC__MIGRATE__STRICT=true`. In an
73
+ immutable image, a recorded-but-missing migration file can only mean a broken build
74
+ or a state-store mismatch — refusing to boot is the correct reaction there.
75
+
76
+ ---
77
+
78
+ ## Compatibility Notes
79
+
80
+ - `status()` return type gained a `missing: string[]` field. Code that destructures
81
+ only `completed`/`pending` continues to work unchanged.
82
+ - **Vendor mode:** `src/core/modules/migrate/migration-runner.ts` and
83
+ `src/core/modules/migrate/cli/migrate-cli.ts` are an atomic pair — sync BOTH
84
+ files together (the CLI passes `strict` into the runner's options interface and
85
+ imports `parseStrictEnv` from the runner).
86
+
87
+ ## Module Documentation
88
+
89
+ - [Migrate module README](../src/core/modules/migrate/README.md) — see "Migration Identity & Strict Mode"
@@ -0,0 +1,127 @@
1
+ # Migration Guide: 11.31.1 → 11.31.2
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None (one behavior change for admins — see below) |
8
+ | **New Features** | Admin cross-user conversation listing (`?all=true`), creator attribution (`createdByUser`) |
9
+ | **Bugfixes** | Non-admins can list their own AI conversations again (was HTTP 403 / GraphQL "Access denied") |
10
+ | **Migration Effort** | None for most projects — no code changes required |
11
+
12
+ ---
13
+
14
+ ## Quick Migration
15
+
16
+ ```bash
17
+ # Update package
18
+ pnpm update @lenne.tech/nest-server@11.31.2
19
+
20
+ # Verify
21
+ pnpm run build && pnpm test
22
+ ```
23
+
24
+ No configuration changes are required. Everything below is automatic (the
25
+ bugfix) or opt-in (the two new features).
26
+
27
+ ---
28
+
29
+ ## What's New in 11.31.2
30
+
31
+ ### 1. Non-admins can list their own AI conversations again (bugfix)
32
+
33
+ `GET /ai/conversations` and the GraphQL `findAiConversations` query returned
34
+ `403` / "Access denied" for every non-admin user, so their own conversations
35
+ never loaded. The list endpoints passed the per-document roles
36
+ `[ADMIN, S_CREATOR, S_SELF]`, which a LIST operation can never satisfy (there is
37
+ no single object to check ownership against), so non-admins were rejected before
38
+ ownership scoping ran.
39
+
40
+ Both endpoints now authorize the list with `S_USER` and scope the result to the
41
+ caller via a server-side `createdBy` filter plus the model's `securityCheck`.
42
+ Cross-user isolation is unchanged — a non-admin still only ever sees their own
43
+ conversations. **No action required**; it simply works after the update.
44
+
45
+ ### 2. Admins can opt in to a cross-user view (`?all=true`)
46
+
47
+ By default every user — **admins included** — now receives only their own
48
+ conversations. An admin can list every user's conversations on demand:
49
+
50
+ ```bash
51
+ # REST
52
+ GET /ai/conversations?all=true
53
+ ```
54
+
55
+ ```graphql
56
+ # GraphQL
57
+ query { findAiConversations(all: true) { id title createdBy createdByUser } }
58
+ ```
59
+
60
+ The flag is ignored for non-admins, so it can never widen a regular user's
61
+ scope. Results are ordered newest-first.
62
+
63
+ ### 3. Creator attribution in the admin cross-user view (`createdByUser`)
64
+
65
+ In the admin cross-user view (`all: true`) each conversation additionally
66
+ carries a resolved `createdByUser` object so the list is attributable to a named
67
+ user:
68
+
69
+ ```jsonc
70
+ {
71
+ "id": "…",
72
+ "title": "…",
73
+ "createdBy": "665f…", // owner user id (always present)
74
+ "createdByUser": { // only in the admin all-view
75
+ "id": "665f…",
76
+ "email": "user@example.com",
77
+ "firstName": "…",
78
+ "lastName": "…",
79
+ "username": "…"
80
+ }
81
+ }
82
+ ```
83
+
84
+ On a normal own-only fetch `createdByUser` is left unset (the owner is the
85
+ caller, so no lookup is needed).
86
+
87
+ ---
88
+
89
+ ## Behavior Change (admins only)
90
+
91
+ Before 11.31.2, `GET /ai/conversations` returned **all** users' conversations
92
+ for an admin by default. It now returns the **admin's own** conversations by
93
+ default, matching every other role. If your admin UI relied on the unscoped
94
+ list, request the cross-user view explicitly with `?all=true` (REST) or
95
+ `all: true` (GraphQL). Non-admin behavior is unchanged.
96
+
97
+ ---
98
+
99
+ ## Compatibility Notes
100
+
101
+ - `getConversation` / `deleteConversation` (single-object operations) are
102
+ unchanged — they still use `[ADMIN, S_CREATOR, S_SELF]`, which is correct
103
+ because a single loaded object exists to check ownership against.
104
+ - Projects that extend `CoreAiConversationService` and override its constructor
105
+ must forward the new `@InjectConnection()` parameter to `super(...)` (used to
106
+ resolve creators for the admin view). Projects that do not subclass the
107
+ service are unaffected.
108
+
109
+ ---
110
+
111
+ ## Module Documentation
112
+
113
+ ### AI Module
114
+
115
+ - **README:** [src/core/modules/ai/README.md](../src/core/modules/ai/README.md)
116
+ - **Integration Checklist:** [src/core/modules/ai/INTEGRATION-CHECKLIST.md](../src/core/modules/ai/INTEGRATION-CHECKLIST.md)
117
+ - **Reference Implementation:** `src/server/modules/ai/`
118
+ - **Key Files:**
119
+ - `core-ai.controller.ts` / `core-ai.resolver.ts` — list endpoints delegate to the shared service method
120
+ - `services/core-ai-conversation.service.ts` — `findForCurrentUser()` (shared owner-scoped list + admin all-scope + creator attribution)
121
+ - `models/core-ai-conversation.model.ts` — `createdByUser` field, ownership `securityCheck`
122
+
123
+ ---
124
+
125
+ ## References
126
+
127
+ - [nest-server-starter](https://github.com/lenneTech/nest-server-starter) (reference implementation)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.31.0",
3
+ "version": "11.31.2",
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",
@@ -1,4 +1,4 @@
1
- import { Body, Controller, Delete, Get, Param, Post, Put, Res } from '@nestjs/common';
1
+ import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res } from '@nestjs/common';
2
2
  import { Response } from 'express';
3
3
 
4
4
  import { RESTServiceOptions } from '../../common/decorators/rest-service-options.decorator';
@@ -266,7 +266,9 @@ export class CoreAiController {
266
266
  }
267
267
 
268
268
  /**
269
- * Find the current user's AI conversations (admins see all).
269
+ * Find the current user's AI conversations (own only by default). An admin may
270
+ * pass `?all=true` to list every user's conversations; each result carries its
271
+ * `createdBy` owner id for attribution. The flag is ignored for non-admins.
270
272
  *
271
273
  * The `messages` subdocument array is excluded from the list result — clients
272
274
  * fetching the conversation detail via `getConversation` get the full message
@@ -274,13 +276,14 @@ export class CoreAiController {
274
276
  */
275
277
  @Get('conversations')
276
278
  @Roles(RoleEnum.S_USER)
277
- async findConversations(@RESTServiceOptions() serviceOptions: ServiceOptions): Promise<CoreAiConversation[]> {
278
- const currentUser = serviceOptions?.currentUser;
279
- const filterQuery = currentUser?.roles?.includes(RoleEnum.ADMIN) ? {} : { createdBy: currentUser?.id };
280
- return this.conversationService.find(
281
- { filterQuery },
282
- { ...serviceOptions, roles: [RoleEnum.ADMIN, RoleEnum.S_CREATOR, RoleEnum.S_SELF], select: '-messages' },
283
- );
279
+ async findConversations(
280
+ @RESTServiceOptions() serviceOptions: ServiceOptions,
281
+ @Query('all') all?: string,
282
+ ): Promise<CoreAiConversation[]> {
283
+ // Owner-scoped list shared with the GraphQL resolver — see
284
+ // CoreAiConversationService.findForCurrentUser for the role/ownership rationale.
285
+ // Admins default to their own conversations and opt in to the cross-user view via ?all=true.
286
+ return this.conversationService.findForCurrentUser(serviceOptions, { all: all === 'true' });
284
287
  }
285
288
 
286
289
  /**
@@ -270,21 +270,26 @@ export class CoreAiResolver {
270
270
  }
271
271
 
272
272
  /**
273
- * Find the current user's AI conversations (admins see all).
273
+ * Find the current user's AI conversations (own only by default). An admin may
274
+ * pass `all: true` to list every user's conversations; each result carries its
275
+ * `createdBy` owner id for attribution. The argument is ignored for non-admins.
274
276
  *
275
277
  * The `messages` subdocument array is excluded from the list result — clients
276
278
  * fetching the conversation detail via `getAiConversation` get the full message
277
279
  * history. List payloads stay small even for users with many long conversations.
278
280
  */
279
- @Query(() => [CoreAiConversation], { description: 'Find AI conversations of the current user' })
281
+ @Query(() => [CoreAiConversation], {
282
+ description: "Find AI conversations of the current user (admins may pass all: true to see every user's)",
283
+ })
280
284
  @Roles(RoleEnum.S_USER)
281
- async findAiConversations(@GraphQLServiceOptions() serviceOptions: ServiceOptions): Promise<CoreAiConversation[]> {
282
- const currentUser = serviceOptions?.currentUser;
283
- const filterQuery = currentUser?.roles?.includes(RoleEnum.ADMIN) ? {} : { createdBy: currentUser?.id };
284
- return this.conversationService.find(
285
- { filterQuery },
286
- { ...serviceOptions, roles: [RoleEnum.ADMIN, RoleEnum.S_CREATOR, RoleEnum.S_SELF], select: '-messages' },
287
- );
285
+ async findAiConversations(
286
+ @GraphQLServiceOptions() serviceOptions: ServiceOptions,
287
+ @Args('all', { nullable: true, type: () => Boolean }) all?: boolean,
288
+ ): Promise<CoreAiConversation[]> {
289
+ // Owner-scoped list shared with the REST controller — see
290
+ // CoreAiConversationService.findForCurrentUser for the role/ownership rationale.
291
+ // Admins default to their own conversations and opt in to the cross-user view via all: true.
292
+ return this.conversationService.findForCurrentUser(serviceOptions, { all: all === true });
288
293
  }
289
294
 
290
295
  /**
@@ -6,6 +6,7 @@ import { Restricted } from '../../../common/decorators/restricted.decorator';
6
6
  import { UnifiedField } from '../../../common/decorators/unified-field.decorator';
7
7
  import { RoleEnum } from '../../../common/enums/role.enum';
8
8
  import { CorePersistenceModel } from '../../../common/models/core-persistence.model';
9
+ import { JSON } from '../../../common/scalars/json.scalar';
9
10
  import { CoreAiMessage } from './core-ai-message.model';
10
11
 
11
12
  export type AiConversationDocument = CoreAiConversation & Document;
@@ -45,6 +46,22 @@ export class CoreAiConversation extends CorePersistenceModel {
45
46
  })
46
47
  createdBy?: string = undefined;
47
48
 
49
+ /**
50
+ * Resolved creator identity (`{ id, email, firstName, lastName, username }`),
51
+ * attached at read time ONLY for an admin's cross-user list view (`all: true`)
52
+ * so each conversation is attributable to a named user. Not persisted and left
53
+ * unset on a normal own-only fetch, where the owner is the caller.
54
+ */
55
+ @UnifiedField({
56
+ description:
57
+ 'Resolved creator identity (id, email, name) — set only in the admin cross-user list view, not persisted',
58
+ gqlType: JSON,
59
+ isOptional: true,
60
+ roles: RoleEnum.S_USER,
61
+ type: () => Object,
62
+ })
63
+ createdByUser?: Record<string, any> = undefined;
64
+
48
65
  /**
49
66
  * Conversation messages (appended via $push).
50
67
  */
@@ -76,7 +93,7 @@ export class CoreAiConversation extends CorePersistenceModel {
76
93
  if (force) {
77
94
  return this;
78
95
  }
79
- if (user && (user.hasRole?.(RoleEnum.ADMIN) || (this.createdBy && String(this.createdBy) === user.id))) {
96
+ if (user && (user.hasRole?.(RoleEnum.ADMIN) || (this.createdBy && String(this.createdBy) === String(user.id)))) {
80
97
  return this;
81
98
  }
82
99
  // Hide conversations owned by other users (filtered out of list responses).
@@ -1,7 +1,9 @@
1
1
  import { Inject, Injectable } from '@nestjs/common';
2
- import { InjectModel } from '@nestjs/mongoose';
3
- import { Model, Types } from 'mongoose';
2
+ import { InjectConnection, InjectModel } from '@nestjs/mongoose';
3
+ import { Connection, Model, Types } from 'mongoose';
4
4
 
5
+ import { RoleEnum } from '../../../common/enums/role.enum';
6
+ import { ServiceOptions } from '../../../common/interfaces/service-options.interface';
5
7
  import { CrudService } from '../../../common/services/crud.service';
6
8
  import { CoreModelConstructor } from '../../../common/types/core-model-constructor.type';
7
9
  import { CoreAiConversationCreateInput } from '../inputs/core-ai-conversation-create.input';
@@ -21,8 +23,15 @@ export { AI_CONVERSATION_CLASS, AI_CONVERSATION_MODEL } from '../core-ai.constan
21
23
  * CRUD service for multi-turn {@link CoreAiConversation}s.
22
24
  *
23
25
  * `appendMessage()` adds a turn via `$push` (never round-trips the subdocument
24
- * array through `update()`). Ownership is enforced by the model's `securityCheck`
25
- * and by the resolver/controller passing `S_CREATOR`/`S_SELF` roles.
26
+ * array through `update()`). `findForCurrentUser()` is the shared owner-scoped
27
+ * list used by both the REST controller and the GraphQL resolver.
28
+ *
29
+ * Ownership is enforced differently per operation shape:
30
+ * - single-object `get`/`delete`: the controller/resolver pass
31
+ * `S_CREATOR`/`S_SELF`, evaluated against the loaded `dbObject`.
32
+ * - list (`findForCurrentUser`): a list has no single `dbObject`, so the gate
33
+ * uses `S_USER` and ownership is scoped by the server-computed `createdBy`
34
+ * filterQuery plus the model's `securityCheck`.
26
35
  */
27
36
  @Injectable()
28
37
  export class CoreAiConversationService extends CrudService<
@@ -37,10 +46,107 @@ export class CoreAiConversationService extends CrudService<
37
46
  @InjectModel(AI_CONVERSATION_MODEL) protected override readonly mainDbModel: Model<AiConversationDocument>,
38
47
  @Inject(AI_CONVERSATION_CLASS)
39
48
  protected override readonly mainModelConstructor: CoreModelConstructor<CoreAiConversation>,
49
+ @InjectConnection() protected readonly connection: Connection,
40
50
  ) {
41
51
  super();
42
52
  }
43
53
 
54
+ /**
55
+ * List the AI conversations visible to the current user. By default — for
56
+ * every user, admins included — this returns only the caller's own
57
+ * conversations, newest first. An admin may opt in to the cross-user view
58
+ * (every user's conversations) by passing `{ all: true }`; the flag is
59
+ * ignored for non-admins, so it can never widen a regular user's scope.
60
+ *
61
+ * Each result carries its `createdBy` owner id. In the admin cross-user view
62
+ * (`all: true`) each result additionally carries a resolved `createdByUser`
63
+ * (`{ id, email, firstName, lastName, username }`) so the list is attributable
64
+ * to named users; on a normal own-only fetch that lookup is skipped, since the
65
+ * owner is the caller. The heavy `messages` subdocument array is excluded from
66
+ * the list payload — fetch a single conversation via `get()` for the full
67
+ * message history.
68
+ *
69
+ * Shared by the REST controller (`GET /ai/conversations`) and the GraphQL
70
+ * resolver (`findAiConversations`) so both API surfaces enforce ownership
71
+ * identically and cannot drift apart.
72
+ *
73
+ * A LIST operation has no single `dbObject`, so the per-document roles
74
+ * `S_CREATOR` / `S_SELF` can never be satisfied at the operation level —
75
+ * passing them rejected every non-admin with a 403 before ownership scoping
76
+ * ran. The operation gate therefore uses `[S_USER]`; the server-computed
77
+ * `createdBy` filterQuery and the model `securityCheck` scope the result to
78
+ * the owner.
79
+ */
80
+ async findForCurrentUser(
81
+ serviceOptions?: ServiceOptions,
82
+ options?: { all?: boolean },
83
+ ): Promise<CoreAiConversation[]> {
84
+ const currentUser = serviceOptions?.currentUser;
85
+ const isAdmin = !!currentUser?.roles?.includes(RoleEnum.ADMIN);
86
+ // Default is own-only for everyone; only an admin may opt in to all users' conversations.
87
+ const seeAll = isAdmin && options?.all === true;
88
+ const filterQuery = seeAll ? {} : { createdBy: currentUser?.id };
89
+ const conversations = await this.find(
90
+ { filterQuery, queryOptions: { sort: { createdAt: -1 } } },
91
+ { ...serviceOptions, roles: [RoleEnum.S_USER], select: '-messages' },
92
+ );
93
+ // Resolve creators to named users ONLY for the admin cross-user view — a normal
94
+ // fetch returns just the caller's own conversations, so the owner is already known.
95
+ if (seeAll && conversations.length) {
96
+ await this.attachCreators(conversations);
97
+ }
98
+ return conversations;
99
+ }
100
+
101
+ /**
102
+ * Attach a lightweight resolved creator (`{ id, email, firstName, lastName,
103
+ * username }`) as `createdByUser` to each conversation, so an admin's
104
+ * cross-user list is attributable to named users. Read-only lookup of
105
+ * non-sensitive identity fields via the shared `User` model on the same
106
+ * connection; fails soft (leaves `createdByUser` unset) when no `User` model is
107
+ * registered. Never called on a normal own-only fetch.
108
+ */
109
+ protected async attachCreators(conversations: CoreAiConversation[]): Promise<void> {
110
+ const ids = [
111
+ ...new Set(
112
+ conversations
113
+ .map((conversation) => conversation.createdBy)
114
+ .filter(Boolean)
115
+ .map(String),
116
+ ),
117
+ ];
118
+ if (!ids.length) {
119
+ return;
120
+ }
121
+ let userModel: Model<any>;
122
+ try {
123
+ userModel = this.connection.model('User');
124
+ } catch {
125
+ // No `User` model registered on this connection (exotic setup) — skip attribution.
126
+ return;
127
+ }
128
+ const users = await userModel
129
+ .find({ _id: { $in: ids } })
130
+ .select('email firstName lastName username')
131
+ .lean()
132
+ .exec();
133
+ const byId = new Map(
134
+ (users as any[]).map((user) => [
135
+ String(user._id),
136
+ {
137
+ email: user.email,
138
+ firstName: user.firstName,
139
+ id: String(user._id),
140
+ lastName: user.lastName,
141
+ username: user.username,
142
+ },
143
+ ]),
144
+ );
145
+ for (const conversation of conversations) {
146
+ conversation.createdByUser = byId.get(String(conversation.createdBy));
147
+ }
148
+ }
149
+
44
150
  /**
45
151
  * Append a message to a conversation via `$push` (system-internal; the user
46
152
  * already authorized the prompt that produced it). The array is capped at
@@ -432,6 +432,62 @@ The `migrate` command comes from `@lenne.tech/nest-server` - no external package
432
432
 
433
433
  See the [Migration Guide](./MIGRATION_FROM_NODEPIT.md) for detailed migration instructions from @nodepit.
434
434
 
435
+ ## Migration Identity & Strict Mode
436
+
437
+ ### Extension-agnostic identity (safe ts-node → compiled-JS transition)
438
+
439
+ A migration's identity is its timestamped file stem **without** the `.ts`/`.js` extension
440
+ (`migrationId('1699-foo.ts') === migrationId('1699-foo.js') === '1699-foo'`). `up`, `down`,
441
+ and `list`/`status` all compare identities, not raw filenames.
442
+
443
+ This makes the recommended production setup — running compiled `.js` migrations in the image
444
+ while the state was recorded under `.ts` names via ts-node — safe: already-applied migrations
445
+ are **not** re-run after the switch, and no state rewrite is needed. If both `foo.ts` and
446
+ `foo.js` are present in the migrations directory (e.g. an overlapping `outDir`), they count
447
+ as ONE migration: only the `.js` file is loaded and executed, and a warning is emitted.
448
+
449
+ Note: identity normalization applies to the `MigrationRunner`/CLI path only. The legacy
450
+ `synchronizedUp()` helper drives the external `migrate` package's own state handling and
451
+ keeps raw-filename identity.
452
+
453
+ ### Missing migration files & strict mode
454
+
455
+ Old, already-applied migration files can be deleted (they live in git and can be restored).
456
+ By default the runner **tolerates** a recorded migration whose file is gone:
457
+
458
+ - `migrate up` warns (`[migrate] N recorded migration file(s) missing — tolerated`) and continues, so the server still boots.
459
+ - `migrate list` marks the entry with `(file missing)`.
460
+ - `migrate down` **always fails hard** on a missing rollback file — independent of strict mode. Rollback is an explicit operator action, never a boot path; exiting 0 without rolling anything back would mislead scripts. Restore the file from git, then retry.
461
+
462
+ Strict mode turns the tolerated drift into a hard error (`up` throws, `list` exits non-zero).
463
+ Enable it via any of:
464
+
465
+ | Activation path | Scope |
466
+ | -------------------------------------------- | -------------------------------------------------------------------------------- |
467
+ | `--strict` CLI flag | single invocation |
468
+ | `NSC__MIGRATE__STRICT=1\|true\|yes` env var | CLI **and** programmatic runners (resolved in the `MigrationRunner` constructor) |
469
+ | `new MigrationRunner({ strict: true, ... })` | programmatic |
470
+
471
+ **Recommended for production images:** set `NSC__MIGRATE__STRICT=true` in the container
472
+ environment. In an immutable image, a recorded-but-missing migration file can only mean a
473
+ broken build (empty/miscopied `migrations/` directory) or a state-store mismatch (wrong
474
+ database) — both are conditions where refusing to boot is correct.
475
+
476
+ ### Programmatic usage (MigrationRunner)
477
+
478
+ ```typescript
479
+ import { MigrationRunner, MongoStateStore } from '@lenne.tech/nest-server';
480
+
481
+ const runner = new MigrationRunner({
482
+ migrationsDirectory: './migrations',
483
+ stateStore: new MongoStateStore(process.env.NSC__MONGOOSE__URI),
484
+ strict: true, // optional; defaults to NSC__MIGRATE__STRICT, else false
485
+ });
486
+
487
+ await runner.up();
488
+ const { completed, missing, pending } = await runner.status();
489
+ ```
490
+
435
491
  ## Project Integration
436
492
 
437
493
  The migration utilities are designed to minimize boilerplate in your projects. Instead of copying multiple utility files, you can:
@@ -17,18 +17,21 @@
17
17
  * --store, -s Path to state store module
18
18
  * --compiler, -c Compiler to use (e.g., ts:./path/to/ts-compiler.js)
19
19
  * --template-file, -t Template file for creating migrations
20
+ * --strict Fail if a recorded migration's file is missing (default: off / tolerate).
21
+ * Applies to up/list; down always fails hard on a missing rollback file.
20
22
  */
21
23
 
22
24
  import * as fs from 'fs';
23
25
  import * as path from 'path';
24
26
 
25
- import { MigrationRunner } from '../migration-runner';
27
+ import { MigrationRunner, parseStrictEnv } from '../migration-runner';
26
28
  import { MongoStateStore } from '../mongo-state-store';
27
29
 
28
30
  interface CliOptions {
29
31
  compiler?: string;
30
32
  migrationsDir: string;
31
33
  store?: string;
34
+ strict?: boolean;
32
35
  templateFile?: string;
33
36
  }
34
37
 
@@ -90,9 +93,11 @@ async function listMigrations(options: CliOptions) {
90
93
  const runner = new MigrationRunner({
91
94
  migrationsDirectory: path.resolve(process.cwd(), options.migrationsDir),
92
95
  stateStore,
96
+ strict: options.strict,
93
97
  });
94
98
 
95
99
  const status = await runner.status();
100
+ const missing = new Set(status.missing);
96
101
 
97
102
  console.log('\nMigration Status:');
98
103
  console.log('=================\n');
@@ -100,7 +105,7 @@ async function listMigrations(options: CliOptions) {
100
105
  if (status.completed.length > 0) {
101
106
  console.log('Completed:');
102
107
  status.completed.forEach((name) => {
103
- console.log(` ✓ ${name}`);
108
+ console.log(` ✓ ${name}${missing.has(name) ? ' (file missing)' : ''}`);
104
109
  });
105
110
  console.log('');
106
111
  }
@@ -116,6 +121,14 @@ async function listMigrations(options: CliOptions) {
116
121
  if (status.completed.length === 0 && status.pending.length === 0) {
117
122
  console.log('No migrations found\n');
118
123
  }
124
+
125
+ // In strict mode, integrity drift is an error also on the inspection surface —
126
+ // exit non-zero (via main's error handler) instead of hiding it in the listing.
127
+ if (options.strict && status.missing.length > 0) {
128
+ throw new Error(
129
+ `Strict mode: ${status.missing.length} recorded migration file(s) missing: ${status.missing.join(', ')}`,
130
+ );
131
+ }
119
132
  }
120
133
 
121
134
  /**
@@ -198,6 +211,9 @@ function parseArgs(): { command: string; name?: string; options: CliOptions } {
198
211
  let name: string | undefined;
199
212
  const options: CliOptions = {
200
213
  migrationsDir: './migrations',
214
+ // Off by default: a recorded migration whose file was deleted is tolerated so the
215
+ // server still starts. Enable per-run via `--strict` or globally via NSC__MIGRATE__STRICT.
216
+ strict: parseStrictEnv(),
201
217
  };
202
218
 
203
219
  // Check if second arg is a name (not a flag)
@@ -217,6 +233,8 @@ function parseArgs(): { command: string; name?: string; options: CliOptions } {
217
233
  options.compiler = args[++i];
218
234
  } else if (arg === '--template-file' || arg === '-t') {
219
235
  options.templateFile = args[++i];
236
+ } else if (arg === '--strict') {
237
+ options.strict = true;
220
238
  }
221
239
  }
222
240
 
@@ -254,6 +272,7 @@ async function runDown(options: CliOptions) {
254
272
  const runner = new MigrationRunner({
255
273
  migrationsDirectory: path.resolve(process.cwd(), options.migrationsDir),
256
274
  stateStore,
275
+ strict: options.strict,
257
276
  });
258
277
 
259
278
  await runner.down();
@@ -269,6 +288,7 @@ async function runUp(options: CliOptions) {
269
288
  const runner = new MigrationRunner({
270
289
  migrationsDirectory: path.resolve(process.cwd(), options.migrationsDir),
271
290
  stateStore,
291
+ strict: options.strict,
272
292
  });
273
293
 
274
294
  await runner.up();
@@ -292,6 +312,8 @@ Options:
292
312
  --store, -s <path> Path to state store module
293
313
  --compiler, -c <compiler> Compiler to use (e.g., ts:./path/to/ts-compiler.js)
294
314
  --template-file, -t <path> Template file for creating migrations
315
+ --strict Fail if a recorded migration's file is missing (default: off / tolerate).
316
+ Applies to up/list; down always fails hard on a missing rollback file.
295
317
 
296
318
  Examples:
297
319
  migrate create add-user-email
@@ -302,6 +324,7 @@ Examples:
302
324
 
303
325
  Environment Variables:
304
326
  NODE_ENV Set environment (e.g., development, production)
327
+ NSC__MIGRATE__STRICT 1|true|yes → fail on a missing recorded migration file (default: tolerate)
305
328
  `);
306
329
  }
307
330
 
@@ -313,4 +336,5 @@ if (require.main === module) {
313
336
  });
314
337
  }
315
338
 
316
- export { main };
339
+ // parseArgs is exported for unit testing only (same pattern as resolveCliPath in bin/migrate.js)
340
+ export { main, parseArgs };