From bdf74ddea09d8a2848036cc68d3cff55dd143973 Mon Sep 17 00:00:00 2001 From: James Gatz Date: Thu, 30 Apr 2026 20:24:00 +0200 Subject: [PATCH] feat(Git Sync): Downgrade -> Upgrade path (#9882) * feat: add mechanism to flush newer DB workspaces to disk during downgrade * feat: implement effective Git repository ID handling for project connections * feat: enhance Git repository ID handling for improved project queries and updates --- packages/insomnia/src/account/session.ts | 3 +- .../node-src/services/project.ts | 3 +- .../src/insomnia-data/src/models/project.ts | 59 +++++++++++++ packages/insomnia/src/main/git-service.ts | 48 +++++------ .../src/routes/git.all-connected-repos.tsx | 11 ++- ...ganizationId.project.$projectId._index.tsx | 13 ++- ...ganizationId.project.$projectId.delete.tsx | 6 +- ...ion.$organizationId.project.$projectId.tsx | 4 +- ...ganizationId.project.$projectId.update.tsx | 10 ++- ...aceId.spec.generate-request-collection.tsx | 4 +- ...$projectId.workspace.$workspaceId.spec.tsx | 4 +- ...ject.$projectId.workspace.$workspaceId.tsx | 4 +- .../src/sync/git/repo-file-watcher.ts | 84 ++++++++++++++++++- 13 files changed, 197 insertions(+), 56 deletions(-) diff --git a/packages/insomnia/src/account/session.ts b/packages/insomnia/src/account/session.ts index 838ccf5c52..3b9a7f9c58 100644 --- a/packages/insomnia/src/account/session.ts +++ b/packages/insomnia/src/account/session.ts @@ -225,7 +225,8 @@ async function _removeAllCredentials() { * */ async function _removeGitRepository(repo: GitRepository) { - const projects = await database.find(models.project.type, { gitRepositoryId: repo._id }); + const queryIds = models.project.getQueryableGitRepositoryIds(repo._id); + const projects = await database.find(models.project.type, { gitRepositoryId: { $in: queryIds } }); for (const p of projects) { await services.project.update(p, { gitRepositoryId: models.project.EMPTY_GIT_PROJECT_ID }); } diff --git a/packages/insomnia/src/insomnia-data/node-src/services/project.ts b/packages/insomnia/src/insomnia-data/node-src/services/project.ts index 8deb401175..41d57097ce 100644 --- a/packages/insomnia/src/insomnia-data/node-src/services/project.ts +++ b/packages/insomnia/src/insomnia-data/node-src/services/project.ts @@ -16,8 +16,9 @@ export function getByRemoteId(remoteId: string) { } export function getAllByGitRepositoryIds(gitRepositoryIds: string[]) { + const queryIds = gitRepositoryIds.flatMap(id => models.project.getQueryableGitRepositoryIds(id)); return db.find(type, { - gitRepositoryId: { $in: gitRepositoryIds }, + gitRepositoryId: { $in: queryIds }, }); } diff --git a/packages/insomnia/src/insomnia-data/src/models/project.ts b/packages/insomnia/src/insomnia-data/src/models/project.ts index b5692ca73f..3ca8ada262 100644 --- a/packages/insomnia/src/insomnia-data/src/models/project.ts +++ b/packages/insomnia/src/insomnia-data/src/models/project.ts @@ -15,10 +15,69 @@ export const SCRATCHPAD_PROJECT_ID = `${prefix}_scratchpad`; // This is used to identify Git Projects that are not connected to a remote yet export const EMPTY_GIT_PROJECT_ID = 'empty'; +// Prefix used when encoding a GitRepository._id into gitRepositoryId for downgrade protection. +// The real GitRepository doc has _id = 'git_xxx'. We store 'gr_xxx' on the project so that the +// old app's getById('gr_xxx') returns null — preventing it from touching the git folder. +// The new app swaps the prefix back to recover the real ID. +export const PROTECTED_GIT_REPO_PREFIX = 'gr_'; +const REAL_GIT_REPO_PREFIX = 'git_'; + +/** + * Decode a raw gitRepositoryId string to the real GitRepository._id. + * Handles both the protected ('gr_xxx') and legacy ('git_xxx') formats. + */ +export function decodeRepoId(id: string): string { + if (id.startsWith(PROTECTED_GIT_REPO_PREFIX)) { + return REAL_GIT_REPO_PREFIX + id.slice(PROTECTED_GIT_REPO_PREFIX.length); + } + return id; +} + +/** + * Given a connected GitProject, return the real GitRepository._id. + * Returns null when the project is not connected (gitRepositoryId is 'empty'). + * + * Handles two formats: + * - 'gr_xxx' → protected (new format) → returns 'git_xxx' + * - 'git_xxx' → legacy (pre-migration) → returns 'git_xxx' as-is + */ +export function getEffectiveRepoId(project: GitProject): string | null { + const id = project.gitRepositoryId; + if (id === EMPTY_GIT_PROJECT_ID) return null; + return decodeRepoId(id); +} + +/** + * Encode a real GitRepository._id ('git_xxx') into the protected format ('gr_xxx') + * that is stored on the project's gitRepositoryId field. + */ +export function toProtectedRepoId(gitRepositoryId: string): string { + if (gitRepositoryId.startsWith(REAL_GIT_REPO_PREFIX)) { + return PROTECTED_GIT_REPO_PREFIX + gitRepositoryId.slice(REAL_GIT_REPO_PREFIX.length); + } + return gitRepositoryId; // already protected or unexpected format — pass through +} + +/** + * Return all values that may be stored in Project.gitRepositoryId for a given real + * GitRepository._id, covering both legacy ('git_xxx') and protected ('gr_xxx') forms. + * Use this when building DB queries that must match projects regardless of which + * storage format they were written with. + */ +export function getQueryableGitRepositoryIds(gitRepositoryId: string): string[] { + const realId = decodeRepoId(gitRepositoryId); + const protectedId = toProtectedRepoId(realId); + return Array.from(new Set([realId, protectedId])); +} + export function isEmptyGitProject(project: Project) { return project.gitRepositoryId === EMPTY_GIT_PROJECT_ID; } +export function isConnectedGitProject(project: Project): project is GitProject { + return isGitProject(project) && getEffectiveRepoId(project) !== null; +} + export const isScratchpadProject = (project: Pick) => project._id === SCRATCHPAD_PROJECT_ID; export const isLocalProject = (project: Pick): project is LocalProject => project.remoteId === null; diff --git a/packages/insomnia/src/main/git-service.ts b/packages/insomnia/src/main/git-service.ts index a2b84db99c..732d630f18 100644 --- a/packages/insomnia/src/main/git-service.ts +++ b/packages/insomnia/src/main/git-service.ts @@ -218,12 +218,10 @@ async function getGitRepository({ projectId, workspaceId }: { projectId: string; invariant(projectId, 'Project ID is required'); const project = await services.project.getById(projectId); invariant(project, 'Project not found'); - invariant(project.gitRepositoryId, 'Project is not linked to a git repository'); - invariant( - project.gitRepositoryId && !models.project.isEmptyGitProject(project), - 'Project is not linked to a git repository', - ); - const gitRepository = await services.gitRepository.getById(project.gitRepositoryId); + invariant(models.project.isConnectedGitProject(project), 'Project is not linked to a git repository'); + const repoId = models.project.getEffectiveRepoId(project); + invariant(repoId, 'Project is not linked to a git repository'); + const gitRepository = await services.gitRepository.getById(repoId); invariant(gitRepository, 'Git Repository not found'); return gitRepository; } @@ -293,22 +291,18 @@ export async function getProjectGitFileIssues({ gitRepositoryId, }: GetProjectGitFileIssuesOptions): Promise { const project = await services.project.getById(projectId); - if ( - !project || - !models.project.isGitProject(project) || - !project.gitRepositoryId || - models.project.isEmptyGitProject(project) - ) { + if (!project || !models.project.isConnectedGitProject(project)) { return []; } - if (gitRepositoryId && gitRepositoryId !== project.gitRepositoryId) { + const effectiveRepoId = models.project.getEffectiveRepoId(project); + if (gitRepositoryId && gitRepositoryId !== effectiveRepoId) { return []; } return mapWorkspaceFileIssues({ - issues: repoFileWatcherRegistry.getProblems(project.gitRepositoryId), - repoId: project.gitRepositoryId, + issues: repoFileWatcherRegistry.getProblems(effectiveRepoId!), + repoId: effectiveRepoId!, metas: await getProjectWorkspacesWithMeta(projectId), workspaceId, }); @@ -1168,7 +1162,7 @@ export const cloneGitRepoAction = async ({ await services.project.update(project, { remoteId: null, - gitRepositoryId: gitRepository._id, + gitRepositoryId: models.project.toProtectedRepoId(gitRepository._id), }); return project; @@ -1177,7 +1171,7 @@ export const cloneGitRepoAction = async ({ const project = await services.project.create({ name: name || gitRepository.uri.split('/').pop() || 'New Git Project', parentId: organizationId, - gitRepositoryId: gitRepository._id, + gitRepositoryId: models.project.toProtectedRepoId(gitRepository._id), }); return project; @@ -1483,7 +1477,8 @@ export const updateGitRepoAction = async ({ let gitRepository: GitRepository | undefined; if (gitRepositoryId && gitRepositoryId !== models.project.EMPTY_GIT_PROJECT_ID) { - gitRepository = await services.gitRepository.getById(gitRepositoryId); + const effectiveId = models.project.decodeRepoId(gitRepositoryId); + gitRepository = await services.gitRepository.getById(effectiveId); invariant(gitRepository, 'GitRepository not found'); } else { const newRepo: Partial = { @@ -1505,7 +1500,7 @@ export const updateGitRepoAction = async ({ const project = await services.project.getById(projectId); invariant(project, 'Project not found'); await services.project.update(project, { - gitRepositoryId: gitRepository._id, + gitRepositoryId: models.project.toProtectedRepoId(gitRepository._id), }); } @@ -2898,14 +2893,12 @@ export async function runAllGitRepoMigrations(): Promise { const failedProjects: { id: string; name: string }[] = []; const allProjects = await services.project.all(); - const gitProjects = allProjects.filter( - (p): p is GitProject => models.project.isGitProject(p) && !models.project.isEmptyGitProject(p), - ); + const gitProjects = allProjects.filter((p): p is GitProject => models.project.isConnectedGitProject(p)); if (gitProjects.length === 0) return { logs, failedProjects }; // Batch-fetch all git repositories in one query instead of N individual lookups. - const repoIds = gitProjects.map(p => p.gitRepositoryId); + const repoIds = gitProjects.map(p => models.project.getEffectiveRepoId(p)).filter(Boolean) as string[]; const gitRepositories = await database.find(models.gitRepository.type, { _id: { $in: repoIds }, }); @@ -2922,7 +2915,7 @@ export async function runAllGitRepoMigrations(): Promise { await Promise.all( gitProjects.map(async project => { - const gitRepository = repoById.get(project.gitRepositoryId); + const gitRepository = repoById.get(models.project.getEffectiveRepoId(project)!); if (!gitRepository) return; const repoId = gitRepository._id; @@ -2950,15 +2943,16 @@ export async function runAllGitRepoMigrations(): Promise { logs.push(`${ts()} [INFO] ["${name}"] Converting to local project`); try { const project = await services.project.getById(id); - if (!project || !project.gitRepositoryId) { + if (!project || !models.project.isConnectedGitProject(project)) { logs.push(`${ts()} [WARN] ["${name}"] Project not found or already local — skipping`); return; } - const gitRepository = await services.gitRepository.getById(project.gitRepositoryId); + const effectiveRepoId = models.project.getEffectiveRepoId(project as GitProject); + const gitRepository = effectiveRepoId ? await services.gitRepository.getById(effectiveRepoId) : null; if (gitRepository) { await services.gitRepository.remove(gitRepository); - logs.push(`${ts()} [INFO] ["${name}"] Removed git repository ${project.gitRepositoryId}`); + logs.push(`${ts()} [INFO] ["${name}"] Removed git repository ${effectiveRepoId}`); } await services.project.update(project, { name, gitRepositoryId: null }); diff --git a/packages/insomnia/src/routes/git.all-connected-repos.tsx b/packages/insomnia/src/routes/git.all-connected-repos.tsx index 767d232ae0..72f33ccf83 100644 --- a/packages/insomnia/src/routes/git.all-connected-repos.tsx +++ b/packages/insomnia/src/routes/git.all-connected-repos.tsx @@ -22,18 +22,17 @@ export async function clientLoader() { const organizationMap = Object.fromEntries(organizations.map(o => [o.id, o])); - const allConnectedGitProjects = allProjects.filter( - project => models.project.isGitProject(project) && !models.project.isEmptyGitProject(project), - ); + const allConnectedGitProjects = allProjects.filter(project => models.project.isConnectedGitProject(project)); const gitRepoURIInfoMap: Record = {}; await Promise.all( - allConnectedGitProjects.map(async ({ gitRepositoryId, name, parentId }) => { + allConnectedGitProjects.map(async project => { + const gitRepositoryId = models.project.isGitProject(project) ? models.project.getEffectiveRepoId(project) : null; if (gitRepositoryId) { const gitRepository = await services.gitRepository.getById(gitRepositoryId); if (gitRepository) { gitRepoURIInfoMap[gitRepository.uri] = { - organizationName: organizationMap[parentId]?.name || '', - projectName: name, + organizationName: organizationMap[project.parentId]?.name || '', + projectName: project.name, }; } } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx index 5d27f16f77..16ff7c29d2 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -136,7 +136,9 @@ export async function getProjectsWithGitRepositories({ parentId: organizationId, }); - const gitRepositoryIds = projects.map(p => p.gitRepositoryId).filter(isNotNullOrUndefined); + const gitRepositoryIds = projects + .map(p => (models.project.isConnectedGitProject(p) ? models.project.getEffectiveRepoId(p) : null)) + .filter(isNotNullOrUndefined); const gitRepositories = await database.find('GitRepository', { _id: { @@ -145,7 +147,10 @@ export async function getProjectsWithGitRepositories({ }); return projects.map(project => { - const gitRepository = gitRepositories.find(gr => gr._id === project.gitRepositoryId); + const effectiveId = models.project.isConnectedGitProject(project) + ? models.project.getEffectiveRepoId(project) + : null; + const gitRepository = gitRepositories.find(gr => gr._id === effectiveId); return { ...project, gitRepository, @@ -403,8 +408,8 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { const projectsSyncStatusPromise = CheckAllProjectSyncStatus(projects); const activeProjectGitRepository = - project && models.project.isGitProject(project) - ? await services.gitRepository.getById(project.gitRepositoryId || '') + project && models.project.isConnectedGitProject(project) + ? await services.gitRepository.getById(models.project.getEffectiveRepoId(project) || '') : null; return { diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx index 342c13a789..d916d2120d 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx @@ -4,6 +4,7 @@ import { href, redirect } from 'react-router'; import { database } from '~/common/database'; import { projectLock } from '~/common/project'; import { services } from '~/insomnia-data'; +import * as models from '~/models'; import { reportGitProjectCount } from '~/routes/organization.$organizationId.project.new'; import { invariant } from '~/utils/invariant'; import { createFetcherSubmitHook, getInitialRouteForOrganization } from '~/utils/router'; @@ -32,8 +33,9 @@ export async function clientAction({ params }: Route.ClientActionArgs) { }); } - if (project.gitRepositoryId) { - const gitRepository = await services.gitRepository.getById(project.gitRepositoryId); + if (models.project.isConnectedGitProject(project)) { + const effectiveRepoId = models.project.isGitProject(project) ? models.project.getEffectiveRepoId(project) : null; + const gitRepository = effectiveRepoId ? await services.gitRepository.getById(effectiveRepoId) : null; gitRepository && (await services.gitRepository.remove(gitRepository)); } diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx index 9060dae2fc..dbd29abf38 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.tsx @@ -29,8 +29,8 @@ export function useProjectLoaderData() { const Component = () => { const data = useProjectLoaderData(); const gitRepositoryId = - data && models.project.isGitProject(data.activeProject) && !models.project.isEmptyGitProject(data.activeProject) - ? data.activeProject.gitRepositoryId + data && models.project.isConnectedGitProject(data.activeProject) + ? models.project.getEffectiveRepoId(data.activeProject) : null; const gitFileIssues = useProjectGitFileIssues({ projectId: data?.activeProject._id, diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx index 77022bf44d..72f2a6d15e 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx @@ -40,7 +40,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) const project = await services.project.getById(projectId); invariant(project, 'Project not found'); - const gitRepository = project.gitRepositoryId ? await services.gitRepository.getById(project.gitRepositoryId) : null; + const effectiveRepoId = models.project.isGitProject(project) ? models.project.getEffectiveRepoId(project) : null; + const gitRepository = effectiveRepoId ? await services.gitRepository.getById(effectiveRepoId) : null; const user = await services.userSession.getOrCreate(); const sessionId = user.id; @@ -165,8 +166,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) }, }); - if (project.gitRepositoryId) { - const gitRepository = await services.gitRepository.getById(project.gitRepositoryId); + if (models.project.isConnectedGitProject(project)) { + const gitRepository = await services.gitRepository.getById(models.project.getEffectiveRepoId(project) || ''); gitRepository && (await services.gitRepository.remove(gitRepository)); } @@ -337,7 +338,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs) // convert from git to local if (storageType === 'local' && project.gitRepositoryId) { - const gitRepository = await services.gitRepository.getById(project.gitRepositoryId); + const effectiveId = models.project.isGitProject(project) ? models.project.getEffectiveRepoId(project) : null; + const gitRepository = effectiveId ? await services.gitRepository.getById(effectiveId) : null; gitRepository && (await services.gitRepository.remove(gitRepository)); await services.project.update(project, { name, gitRepositoryId: null }); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx index 72c983ecf3..6b96e41ec3 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.generate-request-collection.tsx @@ -27,8 +27,8 @@ export async function clientAction({ params }: Route.ClientActionArgs) { const isLintError = (result: IRuleResult) => result.severity === 0; - const gitRepositoryId = models.project.isGitProject(project) - ? project.gitRepositoryId + const gitRepositoryId = models.project.isConnectedGitProject(project) + ? models.project.getEffectiveRepoId(project) : workspaceMeta?.gitRepositoryId; const rulesetPath = gitRepositoryId diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 30d20c9a9a..7e3755f53e 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -84,8 +84,8 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) { const workspaceMeta = await services.workspaceMeta.getByParentId(workspaceId); - const gitRepositoryId = models.project.isGitProject(project) - ? project.gitRepositoryId + const gitRepositoryId = models.project.isConnectedGitProject(project) + ? models.project.getEffectiveRepoId(project) : workspaceMeta?.gitRepositoryId; // we don't run the lint here because it is expensive and slows first render too much // TODO: add this in once we run this loader outside the renderer diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx index 95879eda45..39e6f770cd 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.tsx @@ -103,8 +103,8 @@ export async function clientLoader({ params, request }: Route.ClientLoaderArgs) const activeWorkspaceMeta = await services.workspaceMeta.getOrCreateByParentId(workspaceId); - const gitRepositoryId = models.project.isGitProject(activeProject) - ? activeProject.gitRepositoryId + const gitRepositoryId = models.project.isConnectedGitProject(activeProject) + ? models.project.getEffectiveRepoId(activeProject) : activeWorkspaceMeta.gitRepositoryId; const gitRepository = await services.gitRepository.getById(gitRepositoryId || ''); diff --git a/packages/insomnia/src/sync/git/repo-file-watcher.ts b/packages/insomnia/src/sync/git/repo-file-watcher.ts index 4bba7f51fc..228b60c3c7 100644 --- a/packages/insomnia/src/sync/git/repo-file-watcher.ts +++ b/packages/insomnia/src/sync/git/repo-file-watcher.ts @@ -147,6 +147,11 @@ class RepoFileWatcher { // 1. Load workspace-to-file mappings from the DB for rename detection. await watcher.loadKnownGitFilePaths(); + // 1b. If the DB has newer data than what’s on disk (e.g. the user edited + // requests on the old app during a downgrade), write fresh YAML to + // disk BEFORE importing so those edits are not silently overwritten. + await watcher.flushNewerDbWorkspacesToDisk(); + // 2. Import all YAML files into the DB so it reflects disk state. // This populates lastSyncMtime + lastWrittenHash as a side-effect, // which prevents step 3's watchers from re-importing the same files. @@ -222,10 +227,83 @@ class RepoFileWatcher { } /** - * Import all YAML files in the repo directory into the DB. + * For each workspace linked to this project, if the DB was modified more + * recently than the on-disk YAML, write fresh YAML to disk before the + * initial `importAllFiles` scan. * - * Called during watcher creation and after bulk git operations (clone, pull, - * merge, checkout) so the DB reflects the current disk state. + * This prevents the stale-YAML-wins problem that occurs when: + * 1. User downgrades (old app has no RepoFileWatcher — DB changes aren\u2019t flushed to disk). + * 2. User edits requests via the old app (DB updated, no YAML written). + * 3. User re-upgrades; without this guard those edits would be silently lost. + * + * Written files are recorded in `lastWrittenHash` / `lastSyncMtime` so that + * `importAllFiles` skips them (they are already up-to-date). + */ + private async flushNewerDbWorkspacesToDisk(): Promise { + const workspaces = await services.workspace.findByParentId(this.projectId); + + await Promise.all( + workspaces.map(async workspace => { + try { + const meta = await services.workspaceMeta.getByParentId(workspace._id); + const gitFilePath = meta?.gitFilePath ?? `insomnia.${workspace._id}.yaml`; + const absPath = path.resolve(this.repoDir, gitFilePath); + + // Path-traversal guard + const rel = path.relative(this.repoDir, absPath); + if (rel.startsWith('..') || path.isAbsolute(rel)) return; + + // Get the most recently modified DB document in this workspace\u2019s tree + const allDocs = await db.getWithDescendants(workspace); + let maxDbModified: number = workspace.modified ?? 0; + for (const doc of allDocs) { + const m = (doc as { modified?: number }).modified ?? 0; + if (m > maxDbModified) maxDbModified = m; + } + + // Compare against the on-disk mtime + let fileMtime = 0; + try { + const stat = await fs.promises.stat(absPath); + fileMtime = stat.mtimeMs; + } catch { + // File doesn\u2019t exist yet \u2014 nothing to do; importAllFiles will handle creation. + return; + } + + if (maxDbModified <= fileMtime) return; // disk is up-to-date + + // DB is newer \u2014 write fresh YAML so importAllFiles doesn\u2019t overwrite it + const yamlContent = await getInsomniaV5DataExport({ + workspaceId: workspace._id, + includePrivateEnvironments: false, + }); + if (!yamlContent?.trim()) return; + + await fs.promises.mkdir(path.dirname(absPath), { recursive: true }); + await fs.promises.writeFile(absPath, yamlContent, 'utf8'); + + const hash = contentHash(yamlContent); + const normalised = path.normalize(absPath); + this.lastWrittenHash.set(normalised, hash); + const newStat = await fs.promises.stat(absPath); + this.lastSyncMtime.set(normalised, newStat.mtimeMs); + + console.log( + '[repo-file-watcher] DB newer than disk for workspace', + workspace._id, + '— flushed to', + gitFilePath, + ); + } catch (err) { + console.warn('[repo-file-watcher] flushNewerDbWorkspacesToDisk error for workspace', workspace._id, err); + } + }), + ); + } + + /** + * Import all YAML files in the repo directory into the DB. * * Always bypasses the mtime fast-path (`forceRead`) so every file is read * and compared by content-hash. This makes the method safe to call at any