@memberjunction/core-entities 6.1.4 → 6.2.0-edge.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 (54) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/artifact-extraction/artifact-extractor.d.ts +1 -1
  3. package/dist/artifact-extraction/artifact-extractor.js +2 -2
  4. package/dist/custom/MJConversationDetailEntityExtended.d.ts +1 -1
  5. package/dist/custom/MJConversationDetailEntityExtended.js +5 -5
  6. package/dist/custom/MJTemplateEntityExtended.d.ts +2 -2
  7. package/dist/custom/MJTemplateEntityExtended.js +6 -6
  8. package/dist/custom/MJUserViewEntityExtended.d.ts +4 -4
  9. package/dist/custom/MJUserViewEntityExtended.js +22 -22
  10. package/dist/custom/MJUserViewEntityExtended.js.map +1 -1
  11. package/dist/custom/Permissions/BaseShareEntityExtended.d.ts +8 -0
  12. package/dist/custom/Permissions/BaseShareEntityExtended.d.ts.map +1 -1
  13. package/dist/custom/Permissions/BaseShareEntityExtended.js +20 -4
  14. package/dist/custom/Permissions/BaseShareEntityExtended.js.map +1 -1
  15. package/dist/custom/Permissions/MJAccessControlRuleEntityExtended.js +4 -4
  16. package/dist/custom/Permissions/MJArtifactPermissionEntityExtended.js +5 -5
  17. package/dist/custom/Permissions/MJCollectionPermissionEntityExtended.js +5 -5
  18. package/dist/custom/Permissions/MJDashboardPermissionEntityExtended.js +5 -5
  19. package/dist/custom/ResourcePermissions/MJResourcePermissionEntityExtended.js +2 -2
  20. package/dist/custom/ResourcePermissions/ResourcePermissionEngine.d.ts +2 -2
  21. package/dist/custom/ResourcePermissions/ResourcePermissionEngine.js +5 -5
  22. package/dist/engines/ApplicationSettingEngine.d.ts +1 -1
  23. package/dist/engines/ApplicationSettingEngine.js +8 -8
  24. package/dist/engines/AuditLogTypeEngine.d.ts +1 -1
  25. package/dist/engines/AuditLogTypeEngine.js +4 -4
  26. package/dist/engines/InstanceConfigEngine.d.ts +1 -1
  27. package/dist/engines/InstanceConfigEngine.js +2 -2
  28. package/dist/engines/MCPEngine.d.ts +4 -4
  29. package/dist/engines/MCPEngine.js +24 -24
  30. package/dist/engines/PermissionEngine.d.ts +2 -0
  31. package/dist/engines/PermissionEngine.d.ts.map +1 -1
  32. package/dist/engines/PermissionEngine.js +5 -1
  33. package/dist/engines/PermissionEngine.js.map +1 -1
  34. package/dist/engines/ResourceTypeEngine.d.ts +1 -1
  35. package/dist/engines/ResourceTypeEngine.js +4 -4
  36. package/dist/engines/TypeTablesCache.d.ts +1 -1
  37. package/dist/engines/TypeTablesCache.js +3 -3
  38. package/dist/engines/UserInfoEngine.d.ts +8 -8
  39. package/dist/engines/UserInfoEngine.js +48 -48
  40. package/dist/engines/UserRoutineEngine.d.ts +3 -3
  41. package/dist/engines/UserRoutineEngine.js +15 -15
  42. package/dist/engines/conversations.d.ts +183 -7
  43. package/dist/engines/conversations.d.ts.map +1 -1
  44. package/dist/engines/conversations.js +418 -57
  45. package/dist/engines/conversations.js.map +1 -1
  46. package/dist/generated/entities/__mj.d.ts +1999 -292
  47. package/dist/generated/entities/__mj.d.ts.map +1 -1
  48. package/dist/generated/entities/__mj.js +1430 -13
  49. package/dist/generated/entities/__mj.js.map +1 -1
  50. package/dist/generated/remote_operations.d.ts +100 -0
  51. package/dist/generated/remote_operations.d.ts.map +1 -1
  52. package/dist/generated/remote_operations.js +18 -0
  53. package/dist/generated/remote_operations.js.map +1 -1
  54. package/package.json +5 -5
@@ -1,5 +1,5 @@
1
1
  import { BaseEngine, EntityEventRowIsFree, ResolveEntityEventKey, ResolveEntityEventRow, RunQuery, RunView, TransformSimpleObjectToEntityObject } from "@memberjunction/core";
2
- import { NormalizeUUID, ToEpochMs, UUIDsEqual } from "@memberjunction/global";
2
+ import { EscapeSQLString, NormalizeUUID, ToEpochMs, UUIDsEqual } from "@memberjunction/global";
3
3
  import { BehaviorSubject } from "rxjs";
4
4
  import { ArtifactMetadataEngine } from "./artifacts.js";
5
5
  import { ResourcePermissionEngine } from "../custom/ResourcePermissions/ResourcePermissionEngine.js";
@@ -14,11 +14,11 @@ const CONVERSATIONS_RESOURCE_TYPE_ID = '81D4BC3D-9FEB-EF11-B01A-286B35C04427';
14
14
  * {@link ConversationWindowSourceRow} — single-sourced so server callers cannot drift
15
15
  * from the assembler's requirements.
16
16
  */
17
- export const ConversationWindowFields = ['ID', 'Sequence', 'Role', 'Message', 'SummaryOfEarlierConversation'];
17
+ export const ConversationWindowFields = ['ID', 'Sequence', 'Role', 'Message', 'SummaryOfEarlierConversation', '__mj_CreatedAt'];
18
18
  /**
19
19
  * Helper: parse a raw ConversationDetailComplete row into typed arrays.
20
20
  */
21
- export function parseConversationDetailComplete(queryResult) {
21
+ export function ParseConversationDetailComplete(queryResult) {
22
22
  return {
23
23
  ...queryResult,
24
24
  agentRuns: queryResult.AgentRunsJSON
@@ -32,6 +32,10 @@ export function parseConversationDetailComplete(queryResult) {
32
32
  : []
33
33
  };
34
34
  }
35
+ /** @deprecated Use {@link ParseConversationDetailComplete}. */
36
+ export function parseConversationDetailComplete(queryResult) {
37
+ return ParseConversationDetailComplete(queryResult);
38
+ }
35
39
  /**
36
40
  * Timeline items the UI asks for per page. Mirrors `DEFAULT_TRANSCRIPT_PAGE_SIZE` in
37
41
  * `@memberjunction/ng-conversations` — duplicated rather than imported because the
@@ -160,6 +164,61 @@ function mergeArtifactJSON(junction, version, artifact) {
160
164
  Visibility: artifact.Visibility
161
165
  };
162
166
  }
167
+ /**
168
+ * The row filter that decides which folders a user may see: SHARED ones (no owner)
169
+ * plus their OWN.
170
+ *
171
+ * Exported and used by every list read of 'MJ: Projects', because the rule being in
172
+ * one place is the point. It first shipped inline in LoadProjects, and review caught
173
+ * ProjectSelectorComponent running its own RunView with no ownership clause at all —
174
+ * so personal folder names were still listed in the chat area's Assign Project modal,
175
+ * which is precisely the exposure OwnerUserID exists to close. Gating one reader never
176
+ * gates the others; a second copy of a predicate is a second place to forget it.
177
+ *
178
+ * NOT for the Explorer entity-admin surfaces (the Projects record view and its
179
+ * hierarchy panel). Those are the raw entity browser, where an admin sees every row of
180
+ * every entity, and narrowing them here would be inconsistent with how MJ treats
181
+ * entity administration generally.
182
+ *
183
+ * A missing user gets SHARED ONLY, never every personal folder in the environment:
184
+ * without an identity there is nobody to be the owner of, and widening on absent input
185
+ * is how a personal folder reaches a stranger's list.
186
+ */
187
+ export function BuildProjectVisibilityFilter(userId) {
188
+ const id = String(userId ?? '').trim();
189
+ return id.length > 0
190
+ ? `(OwnerUserID IS NULL OR OwnerUserID='${EscapeSQLString(id)}')`
191
+ : `OwnerUserID IS NULL`;
192
+ }
193
+ /**
194
+ * Turns a failed folder delete into something the person reading it can act on.
195
+ *
196
+ * `Project.ParentID` and `Conversation.ProjectID` are RESTRICT foreign keys, so the delete
197
+ * fails while anything still points at the row. `DeleteProject` clears everything it can
198
+ * reach first, which leaves exactly one interesting residue: a referencing row the caller
199
+ * is not permitted to read, and therefore could not reparent. The raw message for that is a
200
+ * constraint name, about a record whose existence is deliberately hidden from them.
201
+ *
202
+ * The constraint name is the signal, and it is kept in the text — a support engineer needs
203
+ * it — but it is no longer the whole message.
204
+ */
205
+ export function ExplainProjectDeleteFailure(dbMessage) {
206
+ const raw = (dbMessage ?? '').trim();
207
+ if (!raw) {
208
+ return 'Failed to delete folder.';
209
+ }
210
+ const blockedByTree = /FK_Project_Parent/i.test(raw);
211
+ // Constraint names only. A bare /ProjectID/ also matched things like "Invalid column
212
+ // name 'ProjectID'", and answering an unrelated error with "conversations you do not
213
+ // have access to" is worse than passing the raw message through.
214
+ const blockedByConversation = /FK_Conversation_Project/i.test(raw);
215
+ if (blockedByTree || blockedByConversation) {
216
+ const what = blockedByTree ? 'subfolders' : 'conversations';
217
+ return `This folder still contains ${what} that you do not have access to, so it cannot be deleted. `
218
+ + `Ask someone who can see them to move or remove them first. (${raw})`;
219
+ }
220
+ return raw;
221
+ }
163
222
  /**
164
223
  * ConversationEngine provides centralized, reactive caching for conversations,
165
224
  * conversation details (messages), and peripheral data (agent runs, artifacts).
@@ -206,6 +265,25 @@ export class ConversationEngine extends BaseEngine {
206
265
  this._lastEnvironmentId = null;
207
266
  /** Track the environment ID used for the last projects (folders) load */
208
267
  this._lastProjectsEnvironmentId = null;
268
+ /**
269
+ * The user the cached projects were loaded FOR. Part of the cache key because
270
+ * the project read is now user-dependent (personal folders): keyed on the
271
+ * environment alone, a second user in the same process — a server-side caller,
272
+ * an impersonated context — would be served the first user's personal folders
273
+ * from cache and never issue a read of their own.
274
+ */
275
+ this._lastProjectsUserId = null;
276
+ /**
277
+ * Monotonic ticket for conversation loads, to keep the newest ANSWER rather than the
278
+ * newest REQUEST.
279
+ *
280
+ * Bypassing dedup is what makes this necessary: two forced loads in quick succession used
281
+ * to collapse into one request, and now each fires. Without a guard, an out-of-order
282
+ * response leaves the sidebar showing the previous scope — a stale-cache failure traded
283
+ * for an ordering one. Rarer, since it needs two flips inside one response window, but
284
+ * this is the toggle case the bypass exists for, so it is exactly the caller that does it.
285
+ */
286
+ this._conversationsLoadGeneration = 0;
209
287
  /**
210
288
  * For conversations the current user *received* via sharing, this map goes
211
289
  * from `conversationId` to the grantor's display info. Populated by
@@ -263,6 +341,29 @@ export class ConversationEngine extends BaseEngine {
263
341
  GetSharedByInfo(conversationId) {
264
342
  return this._sharedByByConversationId.get(NormalizeUUID(conversationId)) ?? null;
265
343
  }
344
+ /**
345
+ * True when the user may grant others access to the conversation: they own it,
346
+ * or hold an Owner-level grant on it. Mirrors the server's share gate in
347
+ * `MJResourcePermissionEntityExtended`, so the UI never offers a share the save
348
+ * would refuse.
349
+ */
350
+ CanShareConversation(conversation, userId) {
351
+ if (conversation.UserID && UUIDsEqual(conversation.UserID, userId)) {
352
+ return true;
353
+ }
354
+ return this.GetSharedByInfo(conversation.ID)?.Level === 'Owner';
355
+ }
356
+ /**
357
+ * True when the user may change the conversation's folder and pin: they own
358
+ * it, or hold an Edit or Owner grant on it. A View grant is read-only.
359
+ */
360
+ CanEditConversation(conversation, userId) {
361
+ if (conversation.UserID && UUIDsEqual(conversation.UserID, userId)) {
362
+ return true;
363
+ }
364
+ const level = this.GetSharedByInfo(conversation.ID)?.Level;
365
+ return level === 'Edit' || level === 'Owner';
366
+ }
266
367
  // ========================================================================
267
368
  // ENGINE CONFIG (BaseEngine pattern)
268
369
  // ========================================================================
@@ -303,35 +404,32 @@ export class ConversationEngine extends BaseEngine {
303
404
  return;
304
405
  }
305
406
  this._lastEnvironmentId = environmentId;
306
- // Include conversations the user has been granted access to via
307
- // `MJ: Resource Permissions`. ResourcePermissionEngine caches the full
308
- // permission table; GetUserAvailableResources filters it to approved
309
- // grants (direct + role-inherited) for this user + resource type.
310
- await ResourcePermissionEngine.Instance.Config(false, contextUser);
311
- const sharedPermissions = ResourcePermissionEngine.Instance
312
- .GetUserAvailableResources(contextUser, CONVERSATIONS_RESOURCE_TYPE_ID);
313
- const sharedConversationIds = sharedPermissions.map((p) => p.ResourceRecordID);
407
+ // Claim a ticket for this load. Anything published below is gated on still holding
408
+ // the newest one — see _conversationsLoadGeneration.
409
+ const generation = ++this._conversationsLoadGeneration;
410
+ const sharedPermissions = await this.getSharedConversationPermissions(contextUser);
411
+ const filter = this.buildVisibleConversationsFilter(environmentId, contextUser.ID, sharedPermissions.map((p) => p.ResourceRecordID), options);
314
412
  const rv = new RunView();
315
- const ownershipClause = `UserID='${contextUser.ID}'`;
316
- const sharedClause = sharedConversationIds.length > 0
317
- ? ` OR ID IN (${sharedConversationIds.map((id) => `'${id}'`).join(',')})`
318
- : '';
319
- // Default main-chat view shows Global + Both. App-scoped
320
- // conversations live inside their owning Application's embedded
321
- // surface and are filtered out here. Callers that want to surface
322
- // them (e.g. an "Include app conversations" toggle) pass
323
- // includeApplicationScoped=true to drop the scope predicate.
324
- const scopeClause = options?.includeApplicationScoped
325
- ? ''
326
- : ` AND ApplicationScope IN ('Global', 'Both')`;
327
- const filter = `EnvironmentID='${environmentId}' AND (${ownershipClause}${sharedClause}) AND (IsArchived IS NULL OR IsArchived=0)${scopeClause}`;
328
413
  const result = await rv.RunView({
329
414
  EntityName: 'MJ: Conversations',
330
415
  ExtraFilter: filter,
331
416
  OrderBy: 'IsPinned DESC, __mj_UpdatedAt DESC',
332
417
  MaxRows: 1000,
333
- ResultType: 'entity_object'
418
+ ResultType: 'entity_object',
419
+ // A FORCED reload must reach the server. Without this, an identical
420
+ // RunView within the provider's dedup-linger window returns the
421
+ // previous result — so a caller forcing a reload because the
422
+ // server-side answer changed (a request header or session state
423
+ // the query text does not carry) gets the stale list back and no
424
+ // request goes out.
425
+ BypassCache: forceRefresh
334
426
  }, contextUser);
427
+ // A response that has been overtaken must not publish. Checked here rather than at
428
+ // the top, because what matters is whether a NEWER load started while this one was
429
+ // in flight — and the peripheral work below is skipped for the same reason.
430
+ if (generation !== this._conversationsLoadGeneration) {
431
+ return;
432
+ }
335
433
  if (result.Success) {
336
434
  this._conversations$.next(result.Results || []);
337
435
  }
@@ -357,6 +455,42 @@ export class ConversationEngine extends BaseEngine {
357
455
  // own per-environment guard to avoid redundant reloads.
358
456
  await this.LoadProjects(environmentId, contextUser, forceRefresh);
359
457
  }
458
+ /**
459
+ * Returns an `ExtraFilter` for `MJ: Conversations` that matches exactly the conversations
460
+ * {@link LoadConversations} shows the user: owned by the user or shared with them, not
461
+ * archived, and (unless `includeApplicationScoped`) Global or Both scope.
462
+ *
463
+ * Use it anywhere that reads conversations for display (search, pickers) so those reads
464
+ * cannot show more than the conversation list does. It is not capped, unlike the list load.
465
+ *
466
+ * @param environmentId - The environment to filter conversations by
467
+ * @param contextUser - The user whose conversations to match
468
+ * @param options - `includeApplicationScoped` also matches app-scoped conversations
469
+ */
470
+ async GetVisibleConversationsFilter(environmentId, contextUser, options) {
471
+ const sharedPermissions = await this.getSharedConversationPermissions(contextUser);
472
+ return this.buildVisibleConversationsFilter(environmentId, contextUser.ID, sharedPermissions.map((p) => p.ResourceRecordID), options);
473
+ }
474
+ /**
475
+ * Approved conversation grants (direct and role-inherited) for the user, from
476
+ * `MJ: Resource Permissions`. ResourcePermissionEngine caches the full permission table.
477
+ */
478
+ async getSharedConversationPermissions(contextUser) {
479
+ await ResourcePermissionEngine.Instance.Config(false, contextUser);
480
+ return ResourcePermissionEngine.Instance.GetUserAvailableResources(contextUser, CONVERSATIONS_RESOURCE_TYPE_ID);
481
+ }
482
+ buildVisibleConversationsFilter(environmentId, userId, sharedConversationIds, options) {
483
+ const ownershipClause = `UserID='${userId}'`;
484
+ const sharedClause = sharedConversationIds.length > 0
485
+ ? ` OR ID IN (${sharedConversationIds.map((id) => `'${id}'`).join(',')})`
486
+ : '';
487
+ // The main chat view shows Global and Both. App-scoped conversations live inside
488
+ // their owning Application's embedded surface.
489
+ const scopeClause = options?.includeApplicationScoped
490
+ ? ''
491
+ : ` AND ApplicationScope IN ('Global', 'Both')`;
492
+ return `EnvironmentID='${environmentId}' AND (${ownershipClause}${sharedClause}) AND (IsArchived IS NULL OR IsArchived=0)${scopeClause}`;
493
+ }
360
494
  /**
361
495
  * Populates {@link _sharedByByConversationId}. Resolves grantor emails via
362
496
  * one `MJ: Users` query keyed on the unique set of `SharedByUserID`s.
@@ -397,28 +531,47 @@ export class ConversationEngine extends BaseEngine {
397
531
  }
398
532
  }
399
533
  /**
400
- * Loads the projects (conversation folders) for an environment and emits via Projects$.
401
- * Projects are environment-scoped (not user-scoped) and small, so the full active set
402
- * is cached. Skips reloading when already loaded for the same environment unless forced.
534
+ * Loads the projects (conversation folders) visible to a user in an environment and
535
+ * emits via Projects$.
536
+ *
537
+ * Visible means SHARED (OwnerUserID IS NULL — every folder that existed before
538
+ * ownership was expressible) plus the user's OWN personal folders. A personal folder
539
+ * never reaches anyone else's sidebar. The set is small, so it is cached whole;
540
+ * reloading is skipped only when both the environment AND the user match, because the
541
+ * read now depends on both.
403
542
  *
404
543
  * @param environmentId - The environment to filter projects by
405
- * @param contextUser - The current user context
406
- * @param forceRefresh - If true, reloads even if already cached for this environment
544
+ * @param contextUser - The current user context; also decides which personal folders load
545
+ * @param forceRefresh - If true, reloads even if already cached for this environment/user
407
546
  */
408
547
  async LoadProjects(environmentId, contextUser, forceRefresh = false) {
409
- if (!forceRefresh && this._lastProjectsEnvironmentId === environmentId) {
548
+ const userId = contextUser?.ID ?? null;
549
+ if (!forceRefresh
550
+ && this._lastProjectsEnvironmentId === environmentId
551
+ && this._lastProjectsUserId === userId) {
410
552
  return;
411
553
  }
554
+ // Shared folders, plus this user's own. One definition, shared with every other
555
+ // list read of this entity — see BuildProjectVisibilityFilter.
556
+ const ownership = BuildProjectVisibilityFilter(userId);
412
557
  const rv = new RunView();
413
558
  const result = await rv.RunView({
414
559
  EntityName: 'MJ: Projects',
415
- ExtraFilter: `EnvironmentID='${environmentId}' AND (IsArchived IS NULL OR IsArchived=0)`,
560
+ ExtraFilter: `EnvironmentID='${environmentId}' AND (IsArchived IS NULL OR IsArchived=0)`
561
+ + ` AND ${ownership}`,
416
562
  OrderBy: 'Name ASC',
417
563
  MaxRows: 1000,
418
- ResultType: 'entity_object'
564
+ ResultType: 'entity_object',
565
+ // Same reason as LoadConversations, and the same call: LoadConversations
566
+ // ends by calling this with its own forceRefresh. Without it, the toggle
567
+ // that forces a reload got fresh conversations and the STALE folders they
568
+ // are grouped under, from an identical RunView inside the same linger
569
+ // window. Half a refresh is its own bug, and a confusing one.
570
+ BypassCache: forceRefresh
419
571
  }, contextUser);
420
572
  if (result.Success) {
421
573
  this._lastProjectsEnvironmentId = environmentId;
574
+ this._lastProjectsUserId = userId;
422
575
  this._projects$.next(result.Results || []);
423
576
  }
424
577
  else {
@@ -492,15 +645,41 @@ export class ConversationEngine extends BaseEngine {
492
645
  */
493
646
  async DeleteProject(id, contextUser) {
494
647
  const md = this.ProviderToUse;
648
+ // 0. Resolve the folder itself FIRST. Its ParentID is where the children go, and the
649
+ // cached copy may not be there at all — the cache is narrowed by ownership, and a
650
+ // principal allowed to delete a folder is not necessarily one whose sidebar lists it.
651
+ let project = this._projects$.value.find(p => UUIDsEqual(p.ID, id));
652
+ if (!project) {
653
+ project = await md.GetEntityObject('MJ: Projects', contextUser);
654
+ const loaded = await project.Load(id);
655
+ if (!loaded) {
656
+ throw new Error('Folder not found');
657
+ }
658
+ }
659
+ const newParentId = project.ParentID ?? null;
495
660
  // 1. Unassign conversations directly in this folder
496
661
  const directConversations = this._conversations$.value.filter(c => c.ProjectID && UUIDsEqual(c.ProjectID, id));
497
662
  for (const conv of directConversations) {
498
663
  await this.SaveConversation(conv.ID, { ProjectID: null }, contextUser);
499
664
  }
500
- // 2. Reparent direct child folders to this folder's parent
501
- const target = this._projects$.value.find(p => UUIDsEqual(p.ID, id));
502
- const newParentId = target?.ParentID ?? null;
503
- const childFolders = this._projects$.value.filter(p => p.ParentID && UUIDsEqual(p.ParentID, id));
665
+ // 2. Reparent direct child folders one level up.
666
+ //
667
+ // The child set comes from a READ keyed on ParentID, never from `_projects$`. That
668
+ // cache is narrowed to shared-plus-mine by BuildProjectVisibilityFilter, and a
669
+ // structural operation on the tree cannot be driven by a view of the tree that is
670
+ // missing rows. Someone else's personal folder under a shared parent does not appear
671
+ // in the cache, so it would never be reparented — and `Project.ParentID` is a
672
+ // RESTRICT foreign key with no ON DELETE clause, so the delete below would then fail
673
+ // with a raw constraint message, about a row the user is not allowed to know exists.
674
+ //
675
+ // The read carries NO ownership clause, deliberately. Deleting a folder needs
676
+ // CanDelete on the entity, which the UI role does not hold; the roles that do also
677
+ // hold unfiltered read, which makes them exempt from the row-level filter — so this
678
+ // read returns the complete child set for every principal that can actually reach
679
+ // here. Should a deployment grant delete to a filtered role anyway, the server still
680
+ // narrows the read, and step 3's guard turns the resulting FK failure into something
681
+ // legible rather than "Failed to save project".
682
+ const childFolders = await this.readChildFolders(id, contextUser);
504
683
  if (childFolders.length > 0) {
505
684
  this._selfMutating = true;
506
685
  try {
@@ -515,18 +694,13 @@ export class ConversationEngine extends BaseEngine {
515
694
  finally {
516
695
  this._selfMutating = false;
517
696
  }
518
- // Children were mutated in place — re-emit so subscribers re-read the tree
697
+ // Children were mutated in place — re-emit so subscribers re-read the tree.
698
+ // Reparented children the caller cannot see are not in `_projects$` and stay out
699
+ // of it; the emit is for the ones that are.
519
700
  this._projects$.next([...this._projects$.value]);
520
701
  }
521
- // 3. Delete the now-unreferenced folder
522
- let project = target;
523
- if (!project) {
524
- project = await md.GetEntityObject('MJ: Projects', contextUser);
525
- const loaded = await project.Load(id);
526
- if (!loaded) {
527
- throw new Error('Folder not found');
528
- }
529
- }
702
+ // 3. Delete the now-unreferenced folder.
703
+ //
530
704
  // Remove from the cached list BEFORE calling Delete(). BaseEntity.Delete() calls
531
705
  // NewRecord() which wipes the entity's fields — including ID — so filtering the list
532
706
  // by ID *after* the delete wouldn't match the (now-blank) cached entity and the folder
@@ -545,10 +719,43 @@ export class ConversationEngine extends BaseEngine {
545
719
  if (!deleted) {
546
720
  // Restore the list on failure (the entity wasn't deleted, so its fields are intact)
547
721
  this._projects$.next(projectsBeforeDelete);
548
- throw new Error(project.LatestResult?.CompleteMessage || 'Failed to delete folder');
722
+ throw new Error(ExplainProjectDeleteFailure(project.LatestResult?.CompleteMessage));
549
723
  }
550
724
  return true;
551
725
  }
726
+ /**
727
+ * Every direct child of a folder, read by ParentID with no ownership clause.
728
+ *
729
+ * Separate from `LoadProjects` on purpose: that one answers "what may this user SEE",
730
+ * and this one answers "what actually REFERENCES this row". Only the second is a safe
731
+ * basis for a delete, because an unseen child still holds the foreign key.
732
+ */
733
+ async readChildFolders(parentId, contextUser) {
734
+ // Validated rather than escaped-and-hoped: EscapeSQLString turns an empty value into
735
+ // `ParentID = ''`, which matches nothing — and "no children" is precisely the wrong
736
+ // answer here, because it reads as permission to delete.
737
+ const id = (parentId ?? '').trim();
738
+ if (!id) {
739
+ throw new Error('Cannot read subfolders without a folder id.');
740
+ }
741
+ // The engine's own provider, as DeleteProject uses for the folder itself. `new RunView()`
742
+ // would be the process-global default, which in a multi-provider client can be a
743
+ // different server from the one this delete runs against.
744
+ const rv = RunView.FromMetadataProvider(this.ProviderToUse);
745
+ const result = await rv.RunView({
746
+ EntityName: 'MJ: Projects',
747
+ ExtraFilter: `ParentID='${EscapeSQLString(id)}'`,
748
+ // This read has to be COMPLETE, not merely representative: one child left out
749
+ // is one FK left pointing at the row. Omitting MaxRows is not enough — the
750
+ // entity's own UserViewMaxRows would then apply and truncate silently.
751
+ IgnoreMaxRows: true,
752
+ ResultType: 'entity_object'
753
+ }, contextUser);
754
+ if (!result.Success) {
755
+ throw new Error(`Could not read the folder's subfolders, so it cannot be safely deleted: ${result.ErrorMessage}`);
756
+ }
757
+ return result.Results || [];
758
+ }
552
759
  /**
553
760
  * Creates a new conversation, saves it to the database, and adds it to the cached list.
554
761
  *
@@ -874,6 +1081,106 @@ export class ConversationEngine extends BaseEngine {
874
1081
  this.removeMultipleFromList(successful);
875
1082
  return { Successful: successful, Failed: failed };
876
1083
  }
1084
+ /**
1085
+ * Moves multiple conversations into a folder (project), or out of every folder
1086
+ * when projectId is null. Emits the updated list once for the whole batch.
1087
+ *
1088
+ * @param ids - Conversation IDs to move
1089
+ * @param projectId - Target folder ID, or null for no folder
1090
+ * @param contextUser - The current user context
1091
+ * @returns Per-item successful and failed outcomes
1092
+ */
1093
+ async MoveMultipleConversationsToProject(ids, projectId, contextUser) {
1094
+ return this.saveMultipleConversations(ids, { ProjectID: projectId }, contextUser);
1095
+ }
1096
+ /**
1097
+ * Pins or unpins multiple conversations. Emits the re-sorted list once for the
1098
+ * whole batch, so pinned conversations move to the top in a single UI update.
1099
+ *
1100
+ * @param ids - Conversation IDs to pin or unpin
1101
+ * @param isPinned - True to pin, false to unpin
1102
+ * @param contextUser - The current user context
1103
+ * @returns Per-item successful and failed outcomes
1104
+ */
1105
+ async PinMultipleConversations(ids, isPinned, contextUser) {
1106
+ return this.saveMultipleConversations(ids, { IsPinned: isPinned }, contextUser);
1107
+ }
1108
+ /**
1109
+ * Applies the same field updates to several conversations, one save at a time so
1110
+ * a single rejection cannot fail the batch, then re-emits the list once.
1111
+ * A conversation whose save fails keeps its previous field values in memory.
1112
+ * Conversations the user holds only View access to are refused without a save.
1113
+ */
1114
+ async saveMultipleConversations(ids, updates, contextUser) {
1115
+ const successful = [];
1116
+ const failed = [];
1117
+ if (ids.length === 0) {
1118
+ return { Successful: successful, Failed: failed };
1119
+ }
1120
+ const md = this.ProviderToUse;
1121
+ this._selfMutating = true;
1122
+ try {
1123
+ for (const id of ids) {
1124
+ let conversation = this.GetConversation(id);
1125
+ try {
1126
+ if (!conversation) {
1127
+ const entity = await md.GetEntityObject('MJ: Conversations', contextUser);
1128
+ const loaded = await entity.Load(id);
1129
+ if (!loaded) {
1130
+ failed.push({ ID: id, Name: 'Unknown', Error: 'Conversation not found' });
1131
+ continue;
1132
+ }
1133
+ conversation = entity;
1134
+ }
1135
+ if (!this.CanEditConversation(conversation, contextUser.ID)) {
1136
+ failed.push({
1137
+ ID: id,
1138
+ Name: conversation.Name || 'Unknown',
1139
+ Error: 'You have View access only'
1140
+ });
1141
+ continue;
1142
+ }
1143
+ const previous = {
1144
+ ProjectID: conversation.ProjectID,
1145
+ IsPinned: conversation.IsPinned
1146
+ };
1147
+ this.applyBulkUpdate(conversation, updates);
1148
+ const saved = await conversation.Save();
1149
+ if (saved) {
1150
+ successful.push(conversation.ID);
1151
+ }
1152
+ else {
1153
+ this.applyBulkUpdate(conversation, previous);
1154
+ failed.push({
1155
+ ID: id,
1156
+ Name: conversation.Name || 'Unknown',
1157
+ Error: conversation.LatestResult?.Message || 'Failed to update conversation'
1158
+ });
1159
+ }
1160
+ }
1161
+ catch (error) {
1162
+ failed.push({
1163
+ ID: id,
1164
+ Name: conversation?.Name || 'Unknown',
1165
+ Error: error instanceof Error ? error.message : 'Unknown error'
1166
+ });
1167
+ }
1168
+ }
1169
+ }
1170
+ finally {
1171
+ this._selfMutating = false;
1172
+ }
1173
+ if (successful.length > 0) {
1174
+ this._conversations$.next(this.sortConversations(this._conversations$.value));
1175
+ }
1176
+ return { Successful: successful, Failed: failed };
1177
+ }
1178
+ applyBulkUpdate(conversation, updates) {
1179
+ if (updates.ProjectID !== undefined)
1180
+ conversation.ProjectID = updates.ProjectID;
1181
+ if (updates.IsPinned !== undefined)
1182
+ conversation.IsPinned = updates.IsPinned;
1183
+ }
877
1184
  // ========================================================================
878
1185
  // CONVERSATION DETAILS (Messages)
879
1186
  // ========================================================================
@@ -1119,7 +1426,7 @@ export class ConversationEngine extends BaseEngine {
1119
1426
  for (const row of rawData) {
1120
1427
  if (!row.ID)
1121
1428
  continue;
1122
- const parsed = parseConversationDetailComplete(row);
1429
+ const parsed = ParseConversationDetailComplete(row);
1123
1430
  // Agent runs
1124
1431
  if (parsed.agentRuns.length > 0) {
1125
1432
  const agentRunData = parsed.agentRuns[0];
@@ -1330,7 +1637,7 @@ export class ConversationEngine extends BaseEngine {
1330
1637
  newDetail.LoadFromData(row);
1331
1638
  existing.Details.push(newDetail);
1332
1639
  }
1333
- const parsed = parseConversationDetailComplete(row);
1640
+ const parsed = ParseConversationDetailComplete(row);
1334
1641
  // Merge agent runs: update in-place or add.
1335
1642
  //
1336
1643
  // Important: `existingRun` may be a plain JSON object rather than a BaseEntity.
@@ -1444,6 +1751,7 @@ export class ConversationEngine extends BaseEngine {
1444
1751
  * the most recent N messages. Ignored when a boundary exists — the summary already
1445
1752
  * covers everything before it, and cutting into the post-boundary tail would create
1446
1753
  * a coverage gap.
1754
+ * @param options.historyFrom - A history floor: see {@link AssembleContextWindow}.
1447
1755
  * @returns Messages in chronological order, each stamped with
1448
1756
  * {@link ConversationContextMetadata}
1449
1757
  */
@@ -1464,14 +1772,21 @@ export class ConversationEngine extends BaseEngine {
1464
1772
  * @param conversationId - The conversation whose detail rows to load
1465
1773
  * @param contextUser - The requesting user (entity RLS is applied under this user)
1466
1774
  * @param provider - Optional per-request metadata provider; falls back to the global default
1775
+ * @param historyFrom - Optional history floor: only rows written at or after it are loaded,
1776
+ * so nothing before it leaves the database. Pair it with the same `historyFrom` on
1777
+ * {@link AssembleContextWindow}, which also drops the summary of earlier messages.
1467
1778
  * @returns The conversation's rows in Sequence order, shaped for {@link AssembleContextWindow}
1468
- * @throws When the underlying RunView reports failure (never returns a silent empty set)
1779
+ * @throws When the underlying RunView reports failure (never returns a silent empty set),
1780
+ * or when `historyFrom` is an invalid date
1469
1781
  */
1470
- static async LoadWindowRowsFresh(conversationId, contextUser, provider) {
1782
+ static async LoadWindowRowsFresh(conversationId, contextUser, provider, historyFrom) {
1471
1783
  const rv = provider ? RunView.FromMetadataProvider(provider) : new RunView();
1784
+ const conversationFilter = `ConversationID='${conversationId}'`;
1472
1785
  const rows = await rv.RunView({
1473
1786
  EntityName: 'MJ: Conversation Details',
1474
- ExtraFilter: `ConversationID='${conversationId}'`,
1787
+ ExtraFilter: historyFrom
1788
+ ? `${conversationFilter} AND ${ConversationEngine.HistoryFromFilter(historyFrom)}`
1789
+ : conversationFilter,
1475
1790
  OrderBy: 'Sequence ASC',
1476
1791
  Fields: [...ConversationWindowFields],
1477
1792
  ResultType: 'simple',
@@ -1492,13 +1807,22 @@ export class ConversationEngine extends BaseEngine {
1492
1807
  *
1493
1808
  * Accepts the minimal {@link ConversationWindowSourceRow} shape, satisfied by full
1494
1809
  * entities AND `ResultType: 'simple'` rows selecting {@link ConversationWindowFields}.
1810
+ *
1811
+ * **History floor.** With `options.historyFrom` set, the window starts there: rows written
1812
+ * before it are dropped, and so is any persisted summary. A summary folds in the
1813
+ * conversation from its first message — exactly what the floor excludes — so under a
1814
+ * floor none is used, and the window is the most recent `maxTailMessages` rows at or
1815
+ * after the floor. A row whose `__mj_CreatedAt` is missing or unreadable can't be shown
1816
+ * to be after the floor, so it is dropped too.
1495
1817
  */
1496
1818
  static AssembleContextWindow(details, options) {
1497
1819
  const excluded = new Set((options?.excludeDetailIds || []).map(id => NormalizeUUID(id)));
1820
+ const floor = options?.historyFrom ?? null;
1498
1821
  const ordered = details
1499
1822
  .filter(d => !excluded.has(NormalizeUUID(d.ID)))
1823
+ .filter(d => !floor || ConversationEngine.isAtOrAfter(d.__mj_CreatedAt, floor))
1500
1824
  .sort((a, b) => a.Sequence - b.Sequence);
1501
- const boundary = ConversationEngine.findSummaryBoundary(ordered);
1825
+ const boundary = floor ? undefined : ConversationEngine.findSummaryBoundary(ordered);
1502
1826
  if (boundary) {
1503
1827
  const tail = ordered.filter(d => d.Sequence >= boundary.Sequence);
1504
1828
  return [ConversationEngine.buildSummaryMessage(boundary), ...tail.map(d => ConversationEngine.detailToContextMessage(d))];
@@ -1507,6 +1831,27 @@ export class ConversationEngine extends BaseEngine {
1507
1831
  const cap = options?.maxTailMessages;
1508
1832
  return cap && all.length > cap ? all.slice(-cap) : all;
1509
1833
  }
1834
+ /**
1835
+ * The `ExtraFilter` predicate for a history floor: rows written at or after `historyFrom`.
1836
+ * Shared by every reader that honours a floor, so they agree on what it means.
1837
+ *
1838
+ * @param historyFrom The first moment that may be read.
1839
+ * @param column The timestamp column to compare, when the predicate runs against a view
1840
+ * or subquery that names it differently.
1841
+ * @throws RangeError when `historyFrom` is an invalid date — a floor that can't be written
1842
+ * down must fail rather than silently read everything.
1843
+ */
1844
+ static HistoryFromFilter(historyFrom, column = '__mj_CreatedAt') {
1845
+ return `${column} >= '${historyFrom.toISOString()}'`;
1846
+ }
1847
+ /** True when a row's timestamp is at or after the floor; false when it is missing or unreadable. */
1848
+ static isAtOrAfter(createdAt, floor) {
1849
+ if (createdAt == null) {
1850
+ return false;
1851
+ }
1852
+ const time = createdAt instanceof Date ? createdAt.getTime() : Date.parse(createdAt);
1853
+ return !Number.isNaN(time) && time >= floor.getTime();
1854
+ }
1510
1855
  /**
1511
1856
  * Finds the summary boundary: the detail with the HIGHEST `Sequence` whose
1512
1857
  * `SummaryOfEarlierConversation` is non-blank. Older summaries below it simply stop
@@ -1659,6 +2004,7 @@ export class ConversationEngine extends BaseEngine {
1659
2004
  this._detailCache.clear();
1660
2005
  this._lastEnvironmentId = null;
1661
2006
  this._lastProjectsEnvironmentId = null;
2007
+ this._lastProjectsUserId = null;
1662
2008
  }
1663
2009
  // ========================================================================
1664
2010
  // CONVERSATION DETAIL CRUD
@@ -2050,7 +2396,9 @@ export class ConversationEngine extends BaseEngine {
2050
2396
  if (!data) {
2051
2397
  return true;
2052
2398
  }
2053
- // save — only track projects in the loaded environment; drop archived ones.
2399
+ // save — the cached list holds folders that are in the loaded environment, not
2400
+ // archived, AND visible to this user. All three are conditions the save can change,
2401
+ // so all three are re-evaluated here rather than only the first two.
2054
2402
  // No `?.` past the guard above: `data` is non-null here, and optional chaining would say
2055
2403
  // otherwise. It is also what let `mergeDataOntoRecord(…, data)` accept a nullable argument
2056
2404
  // without complaint, since `strictNullChecks` is off in this package.
@@ -2058,8 +2406,21 @@ export class ConversationEngine extends BaseEngine {
2058
2406
  const isArchived = data['IsArchived'] === true;
2059
2407
  const inLoadedEnvironment = !this._lastProjectsEnvironmentId ||
2060
2408
  (environmentId != null && UUIDsEqual(environmentId, this._lastProjectsEnvironmentId));
2409
+ // Ownership, the third condition, and the one the folder dialog's confirm promises.
2410
+ // When someone makes a shared folder personal, every OTHER session holding it must
2411
+ // drop it — merging and keeping would leave the folder in their sidebar until reload,
2412
+ // which is the opposite of what that confirm just told the user would happen.
2413
+ // Mirrors BuildProjectVisibilityFilter: no owner is shared, my id is mine, anything
2414
+ // else is someone else's. An unknown viewer sees shared only, never every personal
2415
+ // folder in the environment — widening on absent input is the failure mode worth
2416
+ // avoiding, and it is the same rule the read path applies.
2417
+ const ownerUserId = data['OwnerUserID'];
2418
+ const isVisibleToViewer = ownerUserId == null || ownerUserId === ''
2419
+ ? true
2420
+ : !!this._lastProjectsUserId && UUIDsEqual(ownerUserId, this._lastProjectsUserId);
2421
+ const belongsInList = !isArchived && inLoadedEnvironment && isVisibleToViewer;
2061
2422
  if (existingIdx >= 0) {
2062
- if (isArchived || !inLoadedEnvironment) {
2423
+ if (!belongsInList) {
2063
2424
  this._projects$.next(current.filter(p => !UUIDsEqual(p.ID, id)));
2064
2425
  }
2065
2426
  else {
@@ -2067,7 +2428,7 @@ export class ConversationEngine extends BaseEngine {
2067
2428
  this._projects$.next([...current]);
2068
2429
  }
2069
2430
  }
2070
- else if (event.baseEntity && !isArchived && inLoadedEnvironment) {
2431
+ else if (event.baseEntity && belongsInList) {
2071
2432
  // New folder from a local event — append the entity object
2072
2433
  this._projects$.next([...current, event.baseEntity]);
2073
2434
  }