Files
insomnia/packages/insomnia-data/node-src/database/database-nedb.ts
T
yaoweiprcandClaude Opus 5.5 70b1d87670 fix: Warn when a Git Sync change cannot be discarded [INS-3830] (#10585)
* fix: Warn when a Git Sync change cannot be discarded [INS-3689]

When a committed YAML file is not in the exact format Insomnia generates
(hand-edited, or written by an older version), discarding it restores the
file, the repo file watcher imports it and re-exports it, and the change
reappears. Users get stuck with a change they can never discard.

Detect this up front by replaying the import -> export round-trip in memory
on the raw index/HEAD content. When the result equals the working copy, the
whole diff is Insomnia re-generating the file, so the Commit Changes modal
shows a warning above the diff and the discard confirmation explains that
the file needs to be committed once.

- Add sync/git/canonical-file.ts, which mirrors database.update (initModel)
  and the getWithDescendants ordering (created, then _id)
- Split getInsomniaV5DataExport into a DB fetch and a pure
  serializeInsomniaV5Export
- Return rawHead/rawStage from GitVCS.fileStatus, since head/stage are
  migrated and reordered for display
- Export initModel from insomnia-data/node

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: Replay the Git Sync round-trip on a scratch database [INS-3689]

The undiscardable-change check reimplemented parts of the import/export
pipeline (initModel, getWithDescendants ordering) to avoid touching the app
database. That copy would silently drift whenever the real pipeline changes.

Run the real pipeline instead: write the imported docs to a scratch
in-memory NeDB instance and export them with getInsomniaV5DataExport.

- Keep NeDB collections, change buffer and listeners per instance in
  createNedbDatabase. They were module-level, so a second instance's init()
  would have replaced the app's collections. flushChangesImpl is now handed
  to the wrapper instead of being exported.
- Add an optional db parameter to getInsomniaV5DataExport and drop the
  serializeInsomniaV5Export split and the initModel export

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: Ignore import timestamps when detecting undiscardable changes [INS-3689]

Import fills a missing meta.created/modified with the current time, so a
hand-added entry without timestamps got a different value in the replay
than in the working copy the watcher wrote earlier. The exact comparison
then failed and the warning never showed, although the change still came
back after every discard.

Compare the replay with the working copy using hasSignificantChanges, the
same rules the Unstaged changes list uses, so differences the list already
ignores no longer hide the warning.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: Reword the undiscardable change warning [INS-3689]

Explain the cause in user terms, using the wording suggested in the ticket.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: Shorten the undiscardable change warning [INS-3689]

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* refactor: Rename committedContent to baselineContent [INS-3689]

The content a discard restores is the staged content when there is one, so
it is not necessarily committed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 09:53:20 +00:00

627 lines
21 KiB
TypeScript

// NeDB Database implementation for main process and Node.js environments
// This is the core database implementation using NeDB as the storage backend.
import os from 'node:os';
import fsPath from 'node:path';
import NeDB from '@seald-io/nedb';
import type {
AllTypes,
ApiSpec,
BaseModel,
ChangeBufferEvent,
ChangeListener,
ChangeType,
ClientCertificate,
CloudProviderCredential,
CookieJar,
DataStoreOptions,
Environment,
GitRepository,
IDatabase,
Operation,
ProjectLintRuleset,
Query,
Workspace,
WorkspaceMeta,
} from 'insomnia-data';
import { models } from 'insomnia-data';
import { generateId } from 'insomnia-data/common';
import { initModel } from './init-model';
import { repairDatabase } from './repair-database';
const getTempPath = (name: string) => {
return name === 'temp' ? os.tmpdir() : fsPath.join(os.tmpdir(), 'insomnia-send-request');
};
type initOptions = DataStoreOptions & { dbPath?: string };
type FlushChangesImpl = (id?: number, fake?: boolean) => Promise<ChangeBufferEvent[] | void>;
/**
* Create the NeDB database implementation for main process and Node.js.
* Every instance keeps its own collections and change listeners, so an extra
* instance (e.g. an in-memory scratch database) never affects another one.
*/
export const createNedbDatabase = <O = initOptions>(
wrapper?: (nedbDatabase: IDatabase<initOptions>, internals: { flushChangesImpl: FlushChangesImpl }) => IDatabase<O>,
) => {
let nedbBucket: Record<AllTypes, NeDB> = {} as Record<AllTypes, NeDB>;
// ~~~~~~~~~~~~~~~~ //
// Change Listeners //
// ~~~~~~~~~~~~~~~~ //
let bufferingChanges = false;
let bufferChangesId = 1;
let changeBuffer: ChangeBufferEvent[] = [];
let changeListeners: ChangeListener[] = [];
/** trigger all changeListeners */
const flushChangesImpl: FlushChangesImpl = async function (id = 0, fake = false) {
// Only flush if ID is 0 or the current flush ID is the same as passed
if (id !== 0 && bufferChangesId !== id) {
return;
}
bufferingChanges = false;
const changes = [...changeBuffer];
changeBuffer = [];
if (changes.length === 0) {
// No work to do
return;
}
if (fake) {
console.log(`[db] Dropped ${changes.length} changes.`);
return;
}
// Notify local listeners too
for (const fn of changeListeners) {
await fn(changes);
}
return changes;
};
const originalDatabase: IDatabase<initOptions> = {
batchModifyDocs: async function ({ upsert = [], remove = [] }: Operation) {
const flushId = await database.bufferChanges();
// Perform from least to most dangerous
await Promise.all(upsert.map(doc => database.update(doc)));
await Promise.all(remove.map(doc => database.unsafeRemove(doc)));
await database.flushChanges(flushId);
},
/** buffers database changes and returns a buffer id, automatically call flushChanges in millis,
* bufferChanges and flushChanges should be called in pair every time documents changes are made to trigger change listeners */
bufferChanges: async function (millis = 1000) {
bufferingChanges = true;
setTimeout(database.flushChanges, millis);
return ++bufferChangesId;
},
/** buffers database changes and returns a buffer id */
bufferChangesIndefinitely: async function () {
bufferingChanges = true;
return ++bufferChangesId;
},
/** return count num of documents matching query */
count: async function <T extends BaseModel>(type: AllTypes, query: Query<T> = {}) {
return nedbBucket[type].countAsync(query);
},
docCreate: async <T extends BaseModel>(type: AllTypes, ...patches: Partial<T>[]) => {
const doc = await initModel<T>(
type,
...patches,
// Fields that the user can't touch
{
type: type,
},
);
return database.insert<T>(doc);
},
docUpdate: async <T extends BaseModel>(originalDoc: T, ...patches: Partial<T>[]) => {
// No need to re-initialize the model during update; originalDoc will be in a valid state by virtue of loading
const doc = await initModel<T>(
originalDoc.type,
originalDoc,
// NOTE: This is before `patches` because we want `patch.modified` to win if it has it
{
modified: Date.now(),
},
...patches,
);
return database.update<T>(doc, patches);
},
/** duplicate doc and its descendents recursively */
duplicate: async function <T extends BaseModel>(originalDoc: T, patch: Partial<T> = {}) {
const flushId = await database.bufferChangesIndefinitely();
try {
const descendantMap = models.getAllDescendantMap();
const idMapping = new Map<string, string>();
const allDocs: { doc: BaseModel; parentId: string }[] = [];
async function collectDescendants(doc: BaseModel): Promise<void> {
const model = models.mustGetModel(doc.type);
idMapping.set(doc._id, generateId(model.prefix));
const validChildTypes = (descendantMap[doc.type] ?? []).filter(t => models.canDuplicate(t));
for (const childType of validChildTypes) {
for (const child of await database.find(childType, { parentId: doc._id })) {
allDocs.push({ doc: child, parentId: doc._id });
await collectDescendants(child);
}
}
}
await collectDescendants(originalDoc);
const updateTime = Date.now();
// Duplicate the root document
const rootRewritten: T = models.rewriteReferences(originalDoc, idMapping);
const rootDoc = {
...rootRewritten,
...patch,
_id: idMapping.get(originalDoc._id)!,
modified: updateTime,
created: updateTime,
type: originalDoc.type,
};
const createdDoc = (await nedbBucket[originalDoc.type].insertAsync(rootDoc)) as T;
notifyOfChange('insert', createdDoc);
// Duplicate all descendants
for (const { doc, parentId } of allDocs) {
const rewritten = models.rewriteReferences(doc, idMapping);
const newDoc = {
...rewritten,
_id: idMapping.get(doc._id)!,
parentId: idMapping.get(parentId)!,
modified: updateTime,
created: updateTime,
type: doc.type,
};
const createdDescendant = await nedbBucket[doc.type].insertAsync(newDoc);
notifyOfChange('insert', createdDescendant);
}
return createdDoc;
} finally {
await database.flushChanges(flushId);
}
},
findOne: async function <T extends BaseModel>(
type: AllTypes,
query: Query<T> | string = {},
sort: Record<string, any> = { created: 1 },
): Promise<T | undefined> {
const doc = await nedbBucket[type].findOneAsync<T>(query).sort(sort);
if (doc === null) {
return undefined;
}
return initModel<T>(type, doc);
},
/** find documents matching query */
find: async function <T extends BaseModel>(
type: AllTypes,
query: Query<T> | string = {},
sort: Record<string, any> = { created: 1 },
limit = 0,
): Promise<T[]> {
if (!nedbBucket[type]) {
console.warn(`[db] No collection for type "${type}"`);
return [];
}
const docs = await nedbBucket[type].findAsync<T>(query).sort(sort).limit(limit);
// TODO: create a db init phase for migrations rather than doing it on every find.
const migrated = [];
for (const rawDoc of docs) {
migrated.push(await initModel<T>(type, rawDoc));
}
return migrated;
},
/** trigger all changeListeners */
flushChanges: async function (id = 0, fake = false) {
await flushChangesImpl(id, fake);
},
/** init in main process */
init: async ({ dbPath, ...config }: initOptions = {}, forceReset = false) => {
if (forceReset) {
changeListeners = [];
nedbBucket = {} as Record<AllTypes, NeDB>;
}
const defaultConfig: NeDB.DataStoreOptions = {
autoload: true,
corruptAlertThreshold: 0.9,
...config,
};
if (!dbPath) {
dbPath = process.env['INSOMNIA_DATA_PATH'] || getTempPath('userData');
}
nedbBucket = {
ApiSpec: new NeDB<ApiSpec>({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.ApiSpec.db'),
}),
CaCertificate: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.CaCertificate.db'),
}),
ClientCertificate: new NeDB<ClientCertificate>({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.ClientCertificate.db'),
}),
CloudCredential: new NeDB<CloudProviderCredential>({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.CloudCredential.db'),
}),
CookieJar: new NeDB<CookieJar>({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.CookieJar.db'),
}),
Environment: new NeDB<Environment>({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.Environment.db'),
}),
GitCredentials: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.GitCredentials.db'),
}),
GitRepository: new NeDB<GitRepository>({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.GitRepository.db'),
}),
GrpcRequest: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.GrpcRequest.db'),
}),
GrpcRequestMeta: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.GrpcRequestMeta.db'),
}),
MockRoute: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.MockRoute.db'),
}),
MockServer: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.MockServer.db'),
}),
McpRequest: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.McpRequest.db'),
}),
McpResponse: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.McpResponse.db'),
}),
McpPayload: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.McpPayload.db'),
}),
OAuth2Token: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.OAuth2Token.db'),
}),
PluginData: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.PluginData.db'),
}),
Project: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.Project.db'),
}),
ProjectLintRuleset: new NeDB<ProjectLintRuleset>({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.ProjectLintRuleset.db'),
}),
ProtoDirectory: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.ProtoDirectory.db'),
}),
ProtoFile: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.ProtoFile.db'),
}),
Request: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.Request.db'),
}),
RequestGroup: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.RequestGroup.db'),
}),
RequestGroupMeta: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.RequestGroupMeta.db'),
}),
RequestMeta: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.RequestMeta.db'),
}),
RequestVersion: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.RequestVersion.db'),
}),
Response: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.Response.db'),
}),
RunnerTestResult: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.RunnerTestResult.db'),
}),
Settings: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.Settings.db'),
}),
SocketIOPayload: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.SocketIOPayload.db'),
}),
SocketIORequest: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.SocketIORequest.db'),
}),
SocketIORequestMeta: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.SocketIORequestMeta.db'),
}),
SocketIOResponse: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.SocketIOResponse.db'),
}),
Stats: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.Stats.db'),
}),
UnitTest: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.UnitTest.db'),
}),
UnitTestResult: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.UnitTestResult.db'),
}),
UnitTestSuite: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.UnitTestSuite.db'),
}),
UserSession: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.UserSession.db'),
}),
WebSocketPayload: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.WebSocketPayload.db'),
}),
WebSocketRequest: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.WebSocketRequest.db'),
}),
WebSocketRequestMeta: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.WebSocketRequestMeta.db'),
}),
WebSocketResponse: new NeDB({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.WebSocketResponse.db'),
}),
Workspace: new NeDB<Workspace>({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.Workspace.db'),
}),
WorkspaceMeta: new NeDB<WorkspaceMeta>({
...defaultConfig,
filename: fsPath.join(dbPath, 'insomnia.WorkspaceMeta.db'),
}),
};
// NOTE: Only repair the DB if we're not running in memory. Repairing here causes tests to hang indefinitely for some reason.
// TODO: Figure out why this makes tests hang
if (!config.inMemoryOnly) {
await repairDatabase();
}
},
insert: async function <T extends BaseModel>(doc: T) {
const docWithDefaults = await initModel<T>(doc.type, doc);
const newDoc = await nedbBucket[doc.type].insertAsync(docWithDefaults);
notifyOfChange('insert', newDoc);
return newDoc;
},
onChange: (callback: ChangeListener) => {
changeListeners.push(callback);
},
/** remove doc and its descendants */
remove: async function <T extends BaseModel>(doc: T) {
const flushId = await database.bufferChanges();
const docs = await database.getWithDescendants(doc);
const docIds = docs.map(d => d._id);
const types = [...new Set(docs.map(d => d.type))];
// Don't really need to wait for this to be over;
types.map(t =>
nedbBucket[t].remove(
{
_id: {
$in: docIds,
},
},
{
multi: true,
},
),
);
docs.map(d => notifyOfChange('remove', d));
await database.flushChanges(flushId);
},
removeWhere: async function <T extends BaseModel>(type: AllTypes, query: Query<T>) {
const flushId = await database.bufferChanges();
for (const doc of await database.find<T>(type, query)) {
const docs = await database.getWithDescendants(doc);
const docIds = docs.map(d => d._id);
const types = [...new Set(docs.map(d => d.type))];
// Don't really need to wait for this to be over;
types.map(t =>
nedbBucket[t].remove(
{
_id: {
$in: docIds,
},
},
{
multi: true,
},
),
);
docs.map(d => notifyOfChange('remove', d));
}
await database.flushChanges(flushId);
},
/** Removes entries without removing their children */
unsafeRemove: async function <T extends BaseModel>(doc: T) {
nedbBucket[doc.type].remove({ _id: doc._id });
notifyOfChange('remove', doc);
},
update: async function <T extends BaseModel>(doc: T, patches: Partial<T>[] = []) {
const docWithDefaults = await initModel<T>(doc.type, doc);
await nedbBucket[doc.type].updateAsync({ _id: docWithDefaults._id }, docWithDefaults, { upsert: true });
notifyOfChange('update', docWithDefaults, patches);
return docWithDefaults;
},
/** get all ancestors of specified types of a document including the original, the order of the returned array is leaf to root */
withAncestors: async function <T extends BaseModel>(doc: T | undefined, types: AllTypes[] = []) {
if (!doc) {
return [];
}
let docsToReturn: T[] = doc ? [doc] : [];
if (types.length === 0) {
types = Object.keys(nedbBucket) as AllTypes[];
}
async function next(docs: T[]): Promise<T[]> {
const foundDocs: T[] = [];
for (const d of docs) {
for (const type of types) {
// If the doc is null, we want to search for parentId === null
const parent = await database.findOne<T>(type, { _id: d.parentId });
parent && foundDocs.push(parent);
}
}
if (foundDocs.length === 0) {
return docsToReturn;
}
// Continue searching for children
docsToReturn = [...docsToReturn, ...foundDocs];
return next(foundDocs);
}
return next([doc]);
},
/**
* Get a document and its descendants. Will use the descendant map to determine which types to query.
* @param doc - The document to get descendants for.
* @param types - Only query specified types, if provided
* @returns A promise that resolves to an array of documents
*/
getWithDescendants: async function <T extends BaseModel>(doc: T, types: AllTypes[] = []) {
if (!doc) return [];
let docsToReturn: BaseModel[] = [doc];
const queryTypesDescendantMap = types.length ? models.generateDescendantMap(types) : models.getAllDescendantMap();
async function findDescendants(docs: BaseModel[]): Promise<BaseModel[]> {
let foundDocs: BaseModel[] = [];
if (docs.length > 0) {
// Find all descendants of the current docs
const promises: Promise<BaseModel[]>[] = [];
const uniqueDescendantTypes = new Set<AllTypes>();
const parentIdsMap = new Map<AllTypes, (string | null)[]>();
for (const d of docs) {
if (d.type) {
queryTypesDescendantMap[d.type]?.forEach(t => {
uniqueDescendantTypes.add(t);
parentIdsMap.set(t, [...(parentIdsMap.get(t) || []), d._id]);
});
}
}
const queryTypes = Array.from(uniqueDescendantTypes);
for (const type of queryTypes) {
// If the doc is null, we want to search for parentId === null
const promise = database.find(type, { parentId: { $in: parentIdsMap.get(type) || [] } });
promises.push(promise);
}
const docBatches = await Promise.all(promises);
foundDocs = [...foundDocs, ...docBatches.flat()];
}
if (foundDocs.length === 0) {
// Didn't find anything. We're done
return docsToReturn;
}
// Continue searching for children
docsToReturn = [...docsToReturn, ...foundDocs];
return findDescendants(foundDocs);
}
return findDescendants([doc]);
},
};
/** push changes into the buffer, so that changeListeners can get change contents when database.flushChanges is called,
* this method should be called whenever a document change happens */
async function notifyOfChange<T extends BaseModel>(event: ChangeType, doc: T, patches: Partial<T>[] = []) {
const updatedDoc = doc;
// TODO: Use object is better than array
changeBuffer.push([event, updatedDoc, patches]);
// Flush right away if we're not buffering
if (!bufferingChanges) {
await database.flushChanges();
}
}
const database = wrapper ? wrapper(originalDatabase, { flushChangesImpl }) : originalDatabase;
return database as IDatabase<O>;
};