* feat: improve shared conversation links * test: Cover Shared Link Lifecycle * test: Cover Shared File Snapshots * fix: address review findings on shared links Stop double-decoding the conversation search term. Express already decodes req.query, so the route's extra decodeURIComponent threw URIError on any term containing a bare percent sign and mangled percent-escape-looking text. The sidebar already sent the term raw, so this failed there too. Advance a share's stored target to its branch tail when an update omits one. Updating from the conversation list could not resolve the tail and reused the stored target verbatim, silently republishing the same snapshot instead of the turns added since. Require revalidation on shared files. Updates now keep the shareId, so the file URL no longer changes and a cached response could outlive a revoked share-files choice; an ETag over the pinned snapshot fields keeps unchanged files on 304. * fix: keep the shared badge across conversation cache replacements isShared is derived per list request and absent from single-conversation payloads, so rename, pin, and the SSE conversation updates dropped it when they swapped a server response into the sidebar cache, hiding the badge until an unrelated list refetch. Carry the cached value forward in updateConvoInAllQueries so every replacing caller is covered, while an explicit value still wins. * test: mock syncStaticTools in server boot specs initializeMCPs now calls syncStaticTools when no MCP servers are configured, but the server boot specs stub ~/server/services/Config without it. Post-listen initialization threw, hit process.exit(1), and took the jest worker down until it exceeded the retry limit. * fix: address codex findings on the shared DataTable and file ETag Keep the published DataTable export bound to the legacy component and ship the design-system table as VirtualizedDataTable, so external consumers of @librechat/client keep the props they compile against. Fold the snapshot's stored location into the shared-file ETag, so a re-published output that keeps its size and revision but moves its object no longer revalidates to a stale 304. Auto-fill the table when a first page is too short to overflow its container, since pagination is otherwise only reachable through the scroll handler. * fix: re-scope share grants before publishing and retry stalled auto-fill Move the shared-link ACL expiration write ahead of the content update. The shareId survives an update, so a failed ACL write after the write-through left the new messages and file snapshot readable at the same URL while the owner saw a 500. Retry a rejected auto-fill fetch up to three times: an unscrollable table has no scroll event to fall back on, and the sentinel alone would strand it on the first page. * fix: follow regenerated branches and pin forks to the payload they saw advanceTargetToBranchTail only walked descendants, so a target replaced by a regeneration (a sibling, not a child) left the update parked on the obsolete branch and published none of the turns that followed. A childless target now hops once to the newest sibling the conversation continued under. A shareId survives an update, so an owner republishing between a viewer's load and their Continue click would resolve targetMessageIndex against different messages. The fork request now carries the payload's updatedAt and is rejected with 409 when it no longer matches; the viewer gets the current version pulled in and can retry. * fix: keep table sorting and legacy backfills from breaking share flows Restore the union formatting a local lint-staged prettier collapsed in data-provider types, which broke the CI lint run. Header clicks now toggle direction instead of cycling through an unsorted state, which the controlled tables translated straight back into the default and made one direction unreachable. Re-arm the auto-fill guard on a sort change: a re-sorted first page arrives with the same row count, and the guard would otherwise suppress paging on a container that still cannot scroll. Lazy fileSnapshots backfills no longer touch updatedAt. That timestamp is the revision a viewer's fork is validated against, so a legacy share's first read would have made the Continue click that followed it fail with a 409. * fix: break pagination ties by id and reset share state per conversation Both list cursors marked a page boundary with values that repeat: conversations by (sort field, updatedAt) and shared links by the sort field alone. Imported chats share a title and a timestamp, so every row tied with the boundary was skipped. Both now carry the boundary row's _id and sort by it last, and the shared-links cursor is an opaque composite the route still validates before querying. The share dialog outlives a switch between conversations, so a link with files disabled left the next conversation's dialog showing the switch off and quietly published without files. The stored choice now falls back to the enabled default, and a stale link no longer sits in the copy field. * fix: keep titleless shared links in the paginated list A share has no title default, and BSON orders a missing title before every string, so encoding the boundary as an empty string skipped the remaining untitled links when sorting Name ascending and re-admitted all of them descending. The cursor now carries the boundary's null rather than flattening it, and because $lt and $gt are type-bracketed against a string, descending adds an explicit clause for the untitled tail that a string comparison can never reach. * style: sort share method imports * fix: fail closed on orphaned share targets and guard snapshot backfills getMessagesUpToTarget walked levels from the roots and returned everything it had accumulated when the target was never reached. An imported or partially deleted branch whose parent is missing therefore published the whole conversation instead of the selected branch; the walk now returns nothing unless it actually reaches the target. A lazy backfill wrote fileSnapshots unconditionally, so a viewer's first read of a legacy link could land after a republish and restore the snapshot it replaced, re-authorizing the stable URL of a file the owner had just removed. The write is now conditional on the link still having no snapshot, and the stored one wins any race. Regenerating a message above the shared tail leaves the whole stored branch childless, so the target walk now climbs to the closest ancestor the conversation continued under instead of stopping at the stored tail's own siblings. Changing the search or sort also returns the table's viewport to the top, since the query holds the previous rows while it refetches. * fix: page through titleless rows on both sides of the cursor The route validator still required a string primary, so the composite cursor the data layer issues for a titleless boundary came back as a 400 and the shared-links table stopped at that page. Conversations had the same type-bracketing gap the shared links just closed: a name-sorted page could not reach conversations with no title, since a comparison against a string never matches a missing field. The cursor now carries the null and the filter spells out the titleless clauses for both directions. * fix: keep the share badge read-only and refresh rows on cell changes ensureLinkPermissions re-granted the owner ACL entry on every call, so rendering the header's shared badge turned ordinary navigation into a permission write. It now checks for the grant first and only migrates a link that still lacks one. A fork carrying a positional target but no revision falls back to the whole share, since nothing proves which payload the index was counted against. The memoized table row compared row data and selection only, so a cell rendering external state (the archived list's pending Restore, for one) kept its stale rendering until the row object itself moved; rows now also compare a marker that moves with the column definitions. * fix: keep the shared badge honest when a delete fails or a link remains A failed delete left the conversation looking unshared: the optimistic snapshot covered only the shared-link queries, not the conversation caches the badge reads. The cleared conversations are now restored with the rest. A conversation can hold one link per target message, so clearing the badge on delete is a guess. The conversation list is invalidated once the mutation settles, letting the server decide from the links that are actually left. * fix: refetch every cached conversation page after deleting a link The invalidation was pinned to page zero, so a conversation cached further down the sidebar kept the badge the optimistic update had already cleared even when another targeted link survived. * fix: treat a failed page fetch as a failed auto-fill React Query resolves fetchNextPage with an error result instead of rejecting, so the rejection handler never ran: the guard stayed armed on the unchanged row count and an unscrollable table could never reach the next page. * refactor: move the share request helpers into the typed backend Cursor validation, page-size clamping and the shared-file cache validator were plain backend logic sitting in the legacy JS route. They now live in packages/api with their own tests, and the route keeps only the Express-side wiring: reading query params, mapping domain error codes to status codes, and writing the response. Also carries the requested file choice into the cache entry the create and update mutations synthesize, since the response never echoes it and the dialog reads a resolved entry with no choice as the enabled default. * fix: hold auto-fill while the replacement page is in flight A search or sort swap keeps the previous rows and hasNextPage on screen while the new first page loads, so the re-armed auto-fill asked for page two against a query that was still fetching its first. An infinite query runs one fetch at a time, so that request could cancel or interfere with the one already out. Both tables now pass their fetching state and the guard waits for it. * fix: stop advertising links a deployment no longer serves The sidebar badge rendered from the derived flag alone, so a deployment that turned shared links off still told owners a link was live while the public routes were unregistered. The share dialog called the link a snapshot, but the payload populates the referenced message documents on every request: an edit to an already-shared message is visible immediately, and Update only adds newly referenced ones. The copy now says that. Scroll pagination inspects a resolved error the way auto-fill already does, since React Query reports a failed page that way instead of rejecting. * a11y: gate the shared conversation label on the feature flag The icon stopped rendering when a deployment turns shared links off, but the row still announced the conversation as shared to screen readers. Both now read the same condition. * fix: accept long title cursors and stop badge work the feature disables The cursor cap was tight enough that a Name-sorted page ending on a long title produced a nextCursor the next request rejected, stranding the rest of the list. It now sits well clear of anything the server can issue. The conversation list skipped straight into the shared-link lookup even where ALLOW_SHARED_LINKS is off, paying a round trip on the sidebar's first page for a badge that is never rendered. A regeneration is newer than what it replaced, so only a newer sibling counts: an older one that still has follow-ups is the branch the target was regenerated away from, and resuming there published turns the target had excluded. * fix: hold scroll pagination while a replacement page loads Resetting the viewport to the top after a search or sort change fires a scroll event, and the retained previous rows still report another page, so the handler asked for page two of a query that was still loading page one. * fix: keep the legacy share migration ahead of the owner-grant shortcut A legacy row keeps its marker until every grant it needs exists, so an owner grant on its own is not proof the migration finished. Reading the marker first means a half-migrated public link still gets its public grant, while a fully migrated one keeps the read-only settled path the badge lookup depends on. |
||
|---|---|---|
| .. | ||
| misc | ||
| src | ||
| .gitignore | ||
| babel.config.cjs | ||
| jest.config.mjs | ||
| LICENSE | ||
| package.json | ||
| README.md | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| tsconfig.spec.json | ||
| tsdown.config.mjs | ||
LibreChat Data Schemas Package
This package provides the database schemas, models, types, and methods for LibreChat using Mongoose ODM.
📁 Package Structure
packages/data-schemas/
├── src/
│ ├── schema/ # Mongoose schema definitions
│ ├── models/ # Model factory functions
│ ├── types/ # TypeScript type definitions
│ ├── methods/ # Database operation methods
│ ├── common/ # Shared constants and enums
│ ├── config/ # Configuration files (winston, etc.)
│ └── index.ts # Main package exports
🏗️ Architecture Patterns
1. Schema Files (src/schema/)
Schema files define the Mongoose schema structure. They follow these conventions:
- Naming: Use lowercase filenames (e.g.,
user.ts,accessRole.ts) - Imports: Import types from
~/typesfor TypeScript support - Exports: Export only the schema as default
Example:
import { Schema } from 'mongoose';
import type { IUser } from '~/types';
const userSchema = new Schema<IUser>(
{
name: { type: String },
email: { type: String, required: true },
// ... other fields
},
{ timestamps: true }
);
export default userSchema;
2. Type Definitions (src/types/)
Type files define TypeScript interfaces and types. They follow these conventions:
- Base Type: Define a plain type without Mongoose Document properties
- Document Interface: Extend the base type with Document and
_id - Enums/Constants: Place related enums in the type file or
common/if shared
Example:
import type { Document, Types } from 'mongoose';
export type User = {
name?: string;
email: string;
// ... other fields
};
export type IUser = User &
Document & {
_id: Types.ObjectId;
};
3. Model Factory Functions (src/models/)
Model files create Mongoose models using factory functions. They follow these conventions:
- Function Name:
create[EntityName]Model - Singleton Pattern: Check if model exists before creating
- Type Safety: Use the corresponding interface from types
Example:
import userSchema from '~/schema/user';
import type * as t from '~/types';
export function createUserModel(mongoose: typeof import('mongoose')) {
return mongoose.models.User || mongoose.model<t.IUser>('User', userSchema);
}
4. Database Methods (src/methods/)
Method files contain database operations for each entity. They follow these conventions:
- Function Name:
create[EntityName]Methods - Return Type: Export a type for the methods object
- Operations: Include CRUD operations and entity-specific queries
Example:
import type { Model } from 'mongoose';
import type { IUser } from '~/types';
export function createUserMethods(mongoose: typeof import('mongoose')) {
async function findUserById(userId: string): Promise<IUser | null> {
const User = mongoose.models.User as Model<IUser>;
return await User.findById(userId).lean();
}
async function createUser(userData: Partial<IUser>): Promise<IUser> {
const User = mongoose.models.User as Model<IUser>;
return await User.create(userData);
}
return {
findUserById,
createUser,
// ... other methods
};
}
export type UserMethods = ReturnType<typeof createUserMethods>;
5. Main Exports (src/index.ts)
The main index file exports:
createModels()- Factory function for all modelscreateMethods()- Factory function for all methods- Type exports from
~/types - Shared utilities and constants
🚀 Adding a New Entity
To add a new entity to the data-schemas package, follow these steps:
Step 1: Create the Type Definition
Create src/types/[entityName].ts:
import type { Document, Types } from 'mongoose';
export type EntityName = {
/** Field description */
fieldName: string;
// ... other fields
};
export type IEntityName = EntityName &
Document & {
_id: Types.ObjectId;
};
Step 2: Update Types Index
Add to src/types/index.ts:
export * from './entityName';
Step 3: Create the Schema
Create src/schema/[entityName].ts:
import { Schema } from 'mongoose';
import type { IEntityName } from '~/types';
const entityNameSchema = new Schema<IEntityName>(
{
fieldName: { type: String, required: true },
// ... other fields
},
{ timestamps: true }
);
export default entityNameSchema;
Step 4: Create the Model Factory
Create src/models/[entityName].ts:
import entityNameSchema from '~/schema/entityName';
import type * as t from '~/types';
export function createEntityNameModel(mongoose: typeof import('mongoose')) {
return (
mongoose.models.EntityName ||
mongoose.model<t.IEntityName>('EntityName', entityNameSchema)
);
}
Step 5: Update Models Index
Add to src/models/index.ts:
- Import the factory function:
import { createEntityNameModel } from './entityName';
- Add to the return object in
createModels():
EntityName: createEntityNameModel(mongoose),
Step 6: Create Database Methods
Create src/methods/[entityName].ts:
import type { Model, Types } from 'mongoose';
import type { IEntityName } from '~/types';
export function createEntityNameMethods(mongoose: typeof import('mongoose')) {
async function findEntityById(id: string | Types.ObjectId): Promise<IEntityName | null> {
const EntityName = mongoose.models.EntityName as Model<IEntityName>;
return await EntityName.findById(id).lean();
}
// ... other methods
return {
findEntityById,
// ... other methods
};
}
export type EntityNameMethods = ReturnType<typeof createEntityNameMethods>;
Step 7: Update Methods Index
Add to src/methods/index.ts:
- Import the methods:
import { createEntityNameMethods, type EntityNameMethods } from './entityName';
- Add to the return object in
createMethods():
...createEntityNameMethods(mongoose),
- Add to the
AllMethodstype:
export type AllMethods = UserMethods &
// ... other methods
EntityNameMethods;
📝 Best Practices
- Consistent Naming: Use lowercase for filenames, PascalCase for types/interfaces
- Type Safety: Always use TypeScript types, avoid
any - JSDoc Comments: Document complex fields and methods
- Indexes: Define database indexes in schema files for query performance
- Validation: Use Mongoose schema validation for data integrity
- Lean Queries: Use
.lean()for read operations when you don't need Mongoose document methods
🔧 Common Patterns
Enums and Constants
Place shared enums in src/common/:
// src/common/permissions.ts
export enum PermissionBits {
VIEW = 1,
EDIT = 2,
DELETE = 4,
SHARE = 8,
}
Compound Indexes
For complex queries, add compound indexes:
schema.index({ field1: 1, field2: 1 });
schema.index(
{ uniqueField: 1 },
{
unique: true,
partialFilterExpression: { uniqueField: { $exists: true } }
}
);
Virtual Properties
Add computed properties using virtuals:
schema.virtual('fullName').get(function() {
return `${this.firstName} ${this.lastName}`;
});
🧪 Testing
When adding new entities, ensure:
- Types compile without errors
- Models can be created successfully
- Methods handle edge cases (null checks, validation)
- Indexes are properly defined for query patterns