From bc1591f658032132039d0dccf74205468987f3dc Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Wed, 22 Jul 2026 20:32:27 +0530 Subject: [PATCH 1/4] feat(db): add dateCreated and lastModified to Repo Populate dates on create and bump lastModified on user-role mutations. Backfill missing fields on NeDB/Mongo startup so Date Created/Modified sorting in the repo list works for existing records. Fixes #1486. --- src/db/file/index.ts | 1 + src/db/file/repo.ts | 50 ++++++++ src/db/index.ts | 10 ++ src/db/mongo/index.ts | 1 + src/db/mongo/repo.ts | 50 +++++++- src/db/types.ts | 8 ++ test/db/db.test.ts | 2 + test/db/mongo/repo.test.ts | 42 +++++-- test/db/repo.date-fields.test.ts | 203 +++++++++++++++++++++++++++++++ 9 files changed, 355 insertions(+), 12 deletions(-) create mode 100644 test/db/repo.date-fields.test.ts diff --git a/src/db/file/index.ts b/src/db/file/index.ts index 0693e860e..05862e835 100644 --- a/src/db/file/index.ts +++ b/src/db/file/index.ts @@ -33,6 +33,7 @@ export const { addUserCanAuthorise, removeUserCanPush, removeUserCanAuthorise, + backfillRepoDates, deleteRepo, } = repo; diff --git a/src/db/file/repo.ts b/src/db/file/repo.ts index 76cc8be9b..7cd608e00 100644 --- a/src/db/file/repo.ts +++ b/src/db/file/repo.ts @@ -106,6 +106,10 @@ export const getRepoById = async (_id: string): Promise => { }; export const createRepo = async (repo: Repo): Promise => { + const now = new Date().toISOString(); + if (!repo.dateCreated) repo.dateCreated = now; + if (!repo.lastModified) repo.lastModified = now; + return new Promise((resolve, reject) => { db.insert(repo, (err, doc) => { // ignore for code coverage as neDB rarely returns errors even for an invalid query @@ -130,6 +134,7 @@ export const addUserCanPush = async (_id: string, user: string): Promise = return; } repo.users?.canPush.push(user); + repo.lastModified = new Date().toISOString(); const options = { multi: false, upsert: false }; @@ -158,6 +163,7 @@ export const addUserCanAuthorise = async (_id: string, user: string): Promise x != user); + repo.lastModified = new Date().toISOString(); const options = { multi: false, upsert: false }; @@ -206,6 +213,7 @@ export const removeUserCanPush = async (_id: string, user: string): Promise x != user); + repo.lastModified = new Date().toISOString(); const options = { multi: false, upsert: false }; @@ -222,6 +230,48 @@ export const removeUserCanPush = async (_id: string, user: string): Promise => { + return new Promise((resolve, reject) => { + db.find( + { $or: [{ dateCreated: { $exists: false } }, { lastModified: { $exists: false } }] }, + (err: Error | null, docs: Repo[]) => { + /* istanbul ignore if */ + if (err) { + reject(err); + return; + } + let updated = 0; + const pending = docs.length; + if (pending === 0) { + resolve(0); + return; + } + docs.forEach((doc) => { + const patch = { + dateCreated: doc.dateCreated ?? fallbackIso, + lastModified: doc.lastModified ?? doc.dateCreated ?? fallbackIso, + }; + db.update({ _id: doc._id }, { $set: patch }, {}, (updateErr) => { + /* istanbul ignore if */ + if (updateErr) { + reject(updateErr); + return; + } + updated += 1; + if (updated === pending) resolve(updated); + }); + }); + }, + ); + }); +}; + export const deleteRepo = async (_id: string): Promise => { return new Promise((resolve, reject) => { db.remove({ _id: _id }, (err) => { diff --git a/src/db/index.ts b/src/db/index.ts index 8c6a19c72..76f37e350 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -37,10 +37,17 @@ const start = () => { if (config.getDatabase().type === 'mongo') { console.log('Loading MongoDB database adaptor'); _sink = mongo; + // #1486: idempotent backfill for existing repos missing date fields + void mongo.backfillRepoDates().then((n) => { + if (n > 0) console.log(`Backfilled dateCreated/lastModified on ${n} repo(s)`); + }); } else if (config.getDatabase().type === 'fs') { console.log('Loading neDB database adaptor'); initializeFolders(); _sink = neDb; + void neDb.backfillRepoDates().then((n) => { + if (n > 0) console.log(`Backfilled dateCreated/lastModified on ${n} repo(s)`); + }); } else { console.error(`Unsupported database type: ${config.getDatabase().type}`); process.exit(1); @@ -111,12 +118,15 @@ export const createUser = async ( }; export const createRepo = async (repo: AuthorisedRepo) => { + const now = new Date().toISOString(); const toCreate = { ...repo, users: { canPush: [], canAuthorise: [], }, + dateCreated: now, + lastModified: now, }; toCreate.name = repo.name.toLowerCase(); diff --git a/src/db/mongo/index.ts b/src/db/mongo/index.ts index e5b0b9f25..b0c4134f3 100644 --- a/src/db/mongo/index.ts +++ b/src/db/mongo/index.ts @@ -33,6 +33,7 @@ export const { addUserCanAuthorise, removeUserCanPush, removeUserCanAuthorise, + backfillRepoDates, deleteRepo, } = repo; diff --git a/src/db/mongo/repo.ts b/src/db/mongo/repo.ts index b2ec4f4df..052e909b7 100644 --- a/src/db/mongo/repo.ts +++ b/src/db/mongo/repo.ts @@ -48,6 +48,10 @@ export const getRepoById = async (_id: string): Promise => { }; export const createRepo = async (repo: Repo): Promise => { + const now = new Date().toISOString(); + if (!repo.dateCreated) repo.dateCreated = now; + if (!repo.lastModified) repo.lastModified = now; + const collection = await connect(collectionName); const response = await collection.insertOne(repo as OptionalId); console.log(`created new repo ${JSON.stringify(repo)}`); @@ -59,25 +63,63 @@ export const createRepo = async (repo: Repo): Promise => { export const addUserCanPush = async (_id: string, user: string): Promise => { user = user.toLowerCase(); const collection = await connect(collectionName); - await collection.updateOne({ _id: new ObjectId(_id) }, { $push: { 'users.canPush': user } }); + await collection.updateOne( + { _id: new ObjectId(_id) }, + { $push: { 'users.canPush': user }, $set: { lastModified: new Date().toISOString() } }, + ); }; export const addUserCanAuthorise = async (_id: string, user: string): Promise => { user = user.toLowerCase(); const collection = await connect(collectionName); - await collection.updateOne({ _id: new ObjectId(_id) }, { $push: { 'users.canAuthorise': user } }); + await collection.updateOne( + { _id: new ObjectId(_id) }, + { $push: { 'users.canAuthorise': user }, $set: { lastModified: new Date().toISOString() } }, + ); }; export const removeUserCanPush = async (_id: string, user: string): Promise => { user = user.toLowerCase(); const collection = await connect(collectionName); - await collection.updateOne({ _id: new ObjectId(_id) }, { $pull: { 'users.canPush': user } }); + await collection.updateOne( + { _id: new ObjectId(_id) }, + { $pull: { 'users.canPush': user }, $set: { lastModified: new Date().toISOString() } }, + ); }; export const removeUserCanAuthorise = async (_id: string, user: string): Promise => { user = user.toLowerCase(); const collection = await connect(collectionName); - await collection.updateOne({ _id: new ObjectId(_id) }, { $pull: { 'users.canAuthorise': user } }); + await collection.updateOne( + { _id: new ObjectId(_id) }, + { $pull: { 'users.canAuthorise': user }, $set: { lastModified: new Date().toISOString() } }, + ); +}; + +/** + * Backfill missing dateCreated/lastModified on existing Mongo repos. + * Idempotent; safe to run on every startup. + */ +export const backfillRepoDates = async ( + fallbackIso = new Date(0).toISOString(), +): Promise => { + const collection = await connect(collectionName); + const result = await collection.updateMany( + { + $or: [{ dateCreated: { $exists: false } }, { lastModified: { $exists: false } }], + }, + [ + { + $set: { + dateCreated: { $ifNull: ['$dateCreated', fallbackIso] }, + lastModified: { + $ifNull: ['$lastModified', { $ifNull: ['$dateCreated', fallbackIso] }], + }, + }, + }, + ], + ); + return result.modifiedCount; }; export const deleteRepo = async (_id: string): Promise => { diff --git a/src/db/types.ts b/src/db/types.ts index 495dd98b2..d1d7662e6 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -58,6 +58,10 @@ export class Repo { name: string; url: string; users: { canPush: string[]; canAuthorise: string[] }; + /** ISO-8601; set on create, never overwritten thereafter */ + dateCreated?: string; + /** ISO-8601; set on create and bumped on repo metadata mutations */ + lastModified?: string; _id?: string; constructor( @@ -66,12 +70,16 @@ export class Repo { url: string, users?: Record, _id?: string, + dateCreated?: string, + lastModified?: string, ) { this.project = project; this.name = name; this.url = url; this.users = users ?? { canPush: [], canAuthorise: [] }; this._id = _id; + this.dateCreated = dateCreated; + this.lastModified = lastModified; } } diff --git a/test/db/db.test.ts b/test/db/db.test.ts index b245be501..f8972a277 100644 --- a/test/db/db.test.ts +++ b/test/db/db.test.ts @@ -19,10 +19,12 @@ import { SAMPLE_REPO } from '../../src/proxy/constants'; vi.mock('../../src/db/mongo', () => ({ getRepoByUrl: vi.fn(), + backfillRepoDates: vi.fn().mockResolvedValue(0), })); vi.mock('../../src/db/file', () => ({ getRepoByUrl: vi.fn(), + backfillRepoDates: vi.fn().mockResolvedValue(0), })); vi.mock('../../src/config', () => ({ diff --git a/test/db/mongo/repo.test.ts b/test/db/mongo/repo.test.ts index 61e9d882c..a240c7e93 100644 --- a/test/db/mongo/repo.test.ts +++ b/test/db/mongo/repo.test.ts @@ -215,6 +215,8 @@ describe('MongoDB Repo', async () => { expect(mockInsertOne).toHaveBeenCalledWith(newRepo); expect(result._id).toBe(insertedId.toString()); expect(result.name).toBe('new-repo'); + expect(result.dateCreated).toEqual(expect.any(String)); + expect(result.lastModified).toEqual(expect.any(String)); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); @@ -230,7 +232,10 @@ describe('MongoDB Repo', async () => { expect(mockConnect).toHaveBeenCalledWith('repos'); expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $push: { 'users.canPush': 'newuser' } }, + { + $push: { 'users.canPush': 'newuser' }, + $set: { lastModified: expect.any(String) }, + }, ); }); @@ -241,7 +246,10 @@ describe('MongoDB Repo', async () => { expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $push: { 'users.canPush': 'uppercase' } }, + { + $push: { 'users.canPush': 'uppercase' }, + $set: { lastModified: expect.any(String) }, + }, ); }); }); @@ -255,7 +263,10 @@ describe('MongoDB Repo', async () => { expect(mockConnect).toHaveBeenCalledWith('repos'); expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $push: { 'users.canAuthorise': 'newadmin' } }, + { + $push: { 'users.canAuthorise': 'newadmin' }, + $set: { lastModified: expect.any(String) }, + }, ); }); @@ -266,7 +277,10 @@ describe('MongoDB Repo', async () => { expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $push: { 'users.canAuthorise': 'admin' } }, + { + $push: { 'users.canAuthorise': 'admin' }, + $set: { lastModified: expect.any(String) }, + }, ); }); }); @@ -280,7 +294,10 @@ describe('MongoDB Repo', async () => { expect(mockConnect).toHaveBeenCalledWith('repos'); expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $pull: { 'users.canPush': 'user1' } }, + { + $pull: { 'users.canPush': 'user1' }, + $set: { lastModified: expect.any(String) }, + }, ); }); @@ -291,7 +308,10 @@ describe('MongoDB Repo', async () => { expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $pull: { 'users.canPush': 'user' } }, + { + $pull: { 'users.canPush': 'user' }, + $set: { lastModified: expect.any(String) }, + }, ); }); }); @@ -305,7 +325,10 @@ describe('MongoDB Repo', async () => { expect(mockConnect).toHaveBeenCalledWith('repos'); expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $pull: { 'users.canAuthorise': 'admin1' } }, + { + $pull: { 'users.canAuthorise': 'admin1' }, + $set: { lastModified: expect.any(String) }, + }, ); }); @@ -316,7 +339,10 @@ describe('MongoDB Repo', async () => { expect(mockUpdateOne).toHaveBeenCalledWith( { _id: new ObjectId(TEST_REPO._id!) }, - { $pull: { 'users.canAuthorise': 'admin' } }, + { + $pull: { 'users.canAuthorise': 'admin' }, + $set: { lastModified: expect.any(String) }, + }, ); }); }); diff --git a/test/db/repo.date-fields.test.ts b/test/db/repo.date-fields.test.ts new file mode 100644 index 000000000..1188bac98 --- /dev/null +++ b/test/db/repo.date-fields.test.ts @@ -0,0 +1,203 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as repoModule from '../../src/db/file/repo'; +import { Repo } from '../../src/db/types'; + +vi.mock('../../src/db/mongo/helper', () => ({ + connect: vi.fn(), +})); + +import { connect } from '../../src/db/mongo/helper'; +import * as mongoRepo from '../../src/db/mongo/repo'; + +describe('Repo dateCreated / lastModified (#1486)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('createRepo persists dateCreated and lastModified as ISO-8601', async () => { + const inserted: Repo[] = []; + vi.spyOn(repoModule.db, 'insert').mockImplementation((doc: unknown, cb: any) => { + const stored = { ...(doc as Repo), _id: 'new-id' }; + inserted.push(stored); + cb(null, stored); + }); + + const before = Date.now(); + const result = await repoModule.createRepo( + new Repo('finos', 'sample', 'https://github.com/finos/sample.git'), + ); + const after = Date.now(); + + expect(result.dateCreated).toEqual(expect.any(String)); + expect(result.lastModified).toEqual(expect.any(String)); + expect(Date.parse(result.dateCreated!)).toBeGreaterThanOrEqual(before); + expect(Date.parse(result.dateCreated!)).toBeLessThanOrEqual(after); + expect(result.dateCreated).toBe(result.lastModified); + expect(inserted[0].dateCreated).toBe(result.dateCreated); + }); + + it('addUserCanPush bumps lastModified but leaves dateCreated unchanged', async () => { + const existing: Repo = { + project: 'finos', + name: 'sample', + url: 'https://github.com/finos/sample.git', + users: { canPush: [], canAuthorise: [] }, + dateCreated: '2020-01-01T00:00:00.000Z', + lastModified: '2020-01-01T00:00:00.000Z', + _id: 'abc', + }; + + vi.spyOn(repoModule.db, 'findOne').mockImplementation((_: unknown, cb: any) => + cb(null, { ...existing }), + ); + let updatedDoc: Repo | null = null; + vi.spyOn(repoModule.db, 'update').mockImplementation( + (_q: unknown, doc: any, _o: unknown, cb: any) => { + updatedDoc = doc; + cb(null, 1); + }, + ); + + await repoModule.addUserCanPush('abc', 'alice'); + + expect(updatedDoc!.dateCreated).toBe('2020-01-01T00:00:00.000Z'); + expect(updatedDoc!.lastModified).not.toBe('2020-01-01T00:00:00.000Z'); + expect(Date.parse(updatedDoc!.lastModified!)).toBeGreaterThan( + Date.parse('2020-01-01T00:00:00.000Z'), + ); + expect(updatedDoc!.users.canPush).toContain('alice'); + }); + + it('sort by dateCreated orders oldest → newest (asc)', () => { + const repos: Pick[] = [ + { name: 'zeta', dateCreated: '2024-06-01T00:00:00.000Z' }, + { name: 'alpha', dateCreated: '2023-01-01T00:00:00.000Z' }, + { name: 'mid', dateCreated: '2023-12-01T00:00:00.000Z' }, + ]; + + const sorted = [...repos].sort( + (a, b) => new Date(a.dateCreated || 0).getTime() - new Date(b.dateCreated || 0).getTime(), + ); + + expect(sorted.map((r) => r.name)).toEqual(['alpha', 'mid', 'zeta']); + }); + + it('sort by lastModified orders oldest → newest (asc)', () => { + const repos: Pick[] = [ + { name: 'fresh', lastModified: '2025-01-01T00:00:00.000Z' }, + { name: 'stale', lastModified: '2022-01-01T00:00:00.000Z' }, + { name: 'mid', lastModified: '2024-01-01T00:00:00.000Z' }, + ]; + + const sorted = [...repos].sort( + (a, b) => new Date(a.lastModified || 0).getTime() - new Date(b.lastModified || 0).getTime(), + ); + + expect(sorted.map((r) => r.name)).toEqual(['stale', 'mid', 'fresh']); + }); + + it('NeDB backfillRepoDates fills missing fields without dropping other props', async () => { + const docs: Repo[] = [ + { + project: 'finos', + name: 'legacy', + url: 'https://github.com/finos/legacy.git', + users: { canPush: ['bob'], canAuthorise: [] }, + _id: 'legacy-id', + }, + { + project: 'finos', + name: 'partial', + url: 'https://github.com/finos/partial.git', + users: { canPush: [], canAuthorise: [] }, + dateCreated: '2021-06-01T00:00:00.000Z', + _id: 'partial-id', + }, + ]; + + vi.spyOn(repoModule.db, 'find').mockImplementation((_q: unknown, cb: any) => cb(null, docs)); + + const patches: Array<{ id: string; set: Record }> = []; + vi.spyOn(repoModule.db, 'update').mockImplementation( + (q: any, update: any, _o: unknown, cb: any) => { + patches.push({ id: q._id, set: update.$set }); + cb(null, 1); + }, + ); + + const fallback = '1970-01-01T00:00:00.000Z'; + const updated = await repoModule.backfillRepoDates(fallback); + + expect(updated).toBe(2); + expect(patches).toEqual( + expect.arrayContaining([ + { + id: 'legacy-id', + set: { dateCreated: fallback, lastModified: fallback }, + }, + { + id: 'partial-id', + set: { + dateCreated: '2021-06-01T00:00:00.000Z', + lastModified: '2021-06-01T00:00:00.000Z', + }, + }, + ]), + ); + }); + + it('NeDB backfillRepoDates is a no-op when all repos already have dates', async () => { + vi.spyOn(repoModule.db, 'find').mockImplementation((_q: unknown, cb: any) => cb(null, [])); + const updateSpy = vi.spyOn(repoModule.db, 'update'); + + const updated = await repoModule.backfillRepoDates(); + + expect(updated).toBe(0); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it('Mongo backfillRepoDates uses updateMany with $ifNull pipeline', async () => { + const updateMany = vi.fn().mockResolvedValue({ modifiedCount: 3 }); + vi.mocked(connect).mockResolvedValue({ updateMany } as any); + + const fallback = '1970-01-01T00:00:00.000Z'; + const updated = await mongoRepo.backfillRepoDates(fallback); + + expect(updated).toBe(3); + expect(updateMany).toHaveBeenCalledWith( + { + $or: [{ dateCreated: { $exists: false } }, { lastModified: { $exists: false } }], + }, + [ + { + $set: { + dateCreated: { $ifNull: ['$dateCreated', fallback] }, + lastModified: { + $ifNull: ['$lastModified', { $ifNull: ['$dateCreated', fallback] }], + }, + }, + }, + ], + ); + }); +}); From a22752d24417b200f4533323b0f3e3919bf28792 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Thu, 23 Jul 2026 00:40:16 +0530 Subject: [PATCH 2/4] fix(db): drop startup backfill per review (#1656) Remove backfillRepoDates and startup hooks; existing-repo migration will follow in a separate PR with a versioned migration framework. --- src/db/file/index.ts | 1 - src/db/file/repo.ts | 42 --------------- src/db/index.ts | 7 --- src/db/mongo/index.ts | 1 - src/db/mongo/repo.ts | 26 --------- test/db/db.test.ts | 2 - test/db/repo.date-fields.test.ts | 92 -------------------------------- 7 files changed, 171 deletions(-) diff --git a/src/db/file/index.ts b/src/db/file/index.ts index 05862e835..0693e860e 100644 --- a/src/db/file/index.ts +++ b/src/db/file/index.ts @@ -33,7 +33,6 @@ export const { addUserCanAuthorise, removeUserCanPush, removeUserCanAuthorise, - backfillRepoDates, deleteRepo, } = repo; diff --git a/src/db/file/repo.ts b/src/db/file/repo.ts index 7cd608e00..4cc3885de 100644 --- a/src/db/file/repo.ts +++ b/src/db/file/repo.ts @@ -230,48 +230,6 @@ export const removeUserCanPush = async (_id: string, user: string): Promise => { - return new Promise((resolve, reject) => { - db.find( - { $or: [{ dateCreated: { $exists: false } }, { lastModified: { $exists: false } }] }, - (err: Error | null, docs: Repo[]) => { - /* istanbul ignore if */ - if (err) { - reject(err); - return; - } - let updated = 0; - const pending = docs.length; - if (pending === 0) { - resolve(0); - return; - } - docs.forEach((doc) => { - const patch = { - dateCreated: doc.dateCreated ?? fallbackIso, - lastModified: doc.lastModified ?? doc.dateCreated ?? fallbackIso, - }; - db.update({ _id: doc._id }, { $set: patch }, {}, (updateErr) => { - /* istanbul ignore if */ - if (updateErr) { - reject(updateErr); - return; - } - updated += 1; - if (updated === pending) resolve(updated); - }); - }); - }, - ); - }); -}; - export const deleteRepo = async (_id: string): Promise => { return new Promise((resolve, reject) => { db.remove({ _id: _id }, (err) => { diff --git a/src/db/index.ts b/src/db/index.ts index 76f37e350..bb21c7561 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -37,17 +37,10 @@ const start = () => { if (config.getDatabase().type === 'mongo') { console.log('Loading MongoDB database adaptor'); _sink = mongo; - // #1486: idempotent backfill for existing repos missing date fields - void mongo.backfillRepoDates().then((n) => { - if (n > 0) console.log(`Backfilled dateCreated/lastModified on ${n} repo(s)`); - }); } else if (config.getDatabase().type === 'fs') { console.log('Loading neDB database adaptor'); initializeFolders(); _sink = neDb; - void neDb.backfillRepoDates().then((n) => { - if (n > 0) console.log(`Backfilled dateCreated/lastModified on ${n} repo(s)`); - }); } else { console.error(`Unsupported database type: ${config.getDatabase().type}`); process.exit(1); diff --git a/src/db/mongo/index.ts b/src/db/mongo/index.ts index b0c4134f3..e5b0b9f25 100644 --- a/src/db/mongo/index.ts +++ b/src/db/mongo/index.ts @@ -33,7 +33,6 @@ export const { addUserCanAuthorise, removeUserCanPush, removeUserCanAuthorise, - backfillRepoDates, deleteRepo, } = repo; diff --git a/src/db/mongo/repo.ts b/src/db/mongo/repo.ts index 052e909b7..8019c89db 100644 --- a/src/db/mongo/repo.ts +++ b/src/db/mongo/repo.ts @@ -96,32 +96,6 @@ export const removeUserCanAuthorise = async (_id: string, user: string): Promise ); }; -/** - * Backfill missing dateCreated/lastModified on existing Mongo repos. - * Idempotent; safe to run on every startup. - */ -export const backfillRepoDates = async ( - fallbackIso = new Date(0).toISOString(), -): Promise => { - const collection = await connect(collectionName); - const result = await collection.updateMany( - { - $or: [{ dateCreated: { $exists: false } }, { lastModified: { $exists: false } }], - }, - [ - { - $set: { - dateCreated: { $ifNull: ['$dateCreated', fallbackIso] }, - lastModified: { - $ifNull: ['$lastModified', { $ifNull: ['$dateCreated', fallbackIso] }], - }, - }, - }, - ], - ); - return result.modifiedCount; -}; - export const deleteRepo = async (_id: string): Promise => { const collection = await connect(collectionName); await collection.deleteMany({ _id: new ObjectId(_id) }); diff --git a/test/db/db.test.ts b/test/db/db.test.ts index f8972a277..b245be501 100644 --- a/test/db/db.test.ts +++ b/test/db/db.test.ts @@ -19,12 +19,10 @@ import { SAMPLE_REPO } from '../../src/proxy/constants'; vi.mock('../../src/db/mongo', () => ({ getRepoByUrl: vi.fn(), - backfillRepoDates: vi.fn().mockResolvedValue(0), })); vi.mock('../../src/db/file', () => ({ getRepoByUrl: vi.fn(), - backfillRepoDates: vi.fn().mockResolvedValue(0), })); vi.mock('../../src/config', () => ({ diff --git a/test/db/repo.date-fields.test.ts b/test/db/repo.date-fields.test.ts index 1188bac98..44441eced 100644 --- a/test/db/repo.date-fields.test.ts +++ b/test/db/repo.date-fields.test.ts @@ -18,13 +18,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as repoModule from '../../src/db/file/repo'; import { Repo } from '../../src/db/types'; -vi.mock('../../src/db/mongo/helper', () => ({ - connect: vi.fn(), -})); - -import { connect } from '../../src/db/mongo/helper'; -import * as mongoRepo from '../../src/db/mongo/repo'; - describe('Repo dateCreated / lastModified (#1486)', () => { beforeEach(() => { vi.clearAllMocks(); @@ -115,89 +108,4 @@ describe('Repo dateCreated / lastModified (#1486)', () => { expect(sorted.map((r) => r.name)).toEqual(['stale', 'mid', 'fresh']); }); - - it('NeDB backfillRepoDates fills missing fields without dropping other props', async () => { - const docs: Repo[] = [ - { - project: 'finos', - name: 'legacy', - url: 'https://github.com/finos/legacy.git', - users: { canPush: ['bob'], canAuthorise: [] }, - _id: 'legacy-id', - }, - { - project: 'finos', - name: 'partial', - url: 'https://github.com/finos/partial.git', - users: { canPush: [], canAuthorise: [] }, - dateCreated: '2021-06-01T00:00:00.000Z', - _id: 'partial-id', - }, - ]; - - vi.spyOn(repoModule.db, 'find').mockImplementation((_q: unknown, cb: any) => cb(null, docs)); - - const patches: Array<{ id: string; set: Record }> = []; - vi.spyOn(repoModule.db, 'update').mockImplementation( - (q: any, update: any, _o: unknown, cb: any) => { - patches.push({ id: q._id, set: update.$set }); - cb(null, 1); - }, - ); - - const fallback = '1970-01-01T00:00:00.000Z'; - const updated = await repoModule.backfillRepoDates(fallback); - - expect(updated).toBe(2); - expect(patches).toEqual( - expect.arrayContaining([ - { - id: 'legacy-id', - set: { dateCreated: fallback, lastModified: fallback }, - }, - { - id: 'partial-id', - set: { - dateCreated: '2021-06-01T00:00:00.000Z', - lastModified: '2021-06-01T00:00:00.000Z', - }, - }, - ]), - ); - }); - - it('NeDB backfillRepoDates is a no-op when all repos already have dates', async () => { - vi.spyOn(repoModule.db, 'find').mockImplementation((_q: unknown, cb: any) => cb(null, [])); - const updateSpy = vi.spyOn(repoModule.db, 'update'); - - const updated = await repoModule.backfillRepoDates(); - - expect(updated).toBe(0); - expect(updateSpy).not.toHaveBeenCalled(); - }); - - it('Mongo backfillRepoDates uses updateMany with $ifNull pipeline', async () => { - const updateMany = vi.fn().mockResolvedValue({ modifiedCount: 3 }); - vi.mocked(connect).mockResolvedValue({ updateMany } as any); - - const fallback = '1970-01-01T00:00:00.000Z'; - const updated = await mongoRepo.backfillRepoDates(fallback); - - expect(updated).toBe(3); - expect(updateMany).toHaveBeenCalledWith( - { - $or: [{ dateCreated: { $exists: false } }, { lastModified: { $exists: false } }], - }, - [ - { - $set: { - dateCreated: { $ifNull: ['$dateCreated', fallback] }, - lastModified: { - $ifNull: ['$lastModified', { $ifNull: ['$dateCreated', fallback] }], - }, - }, - }, - ], - ); - }); }); From b721cdbe44086861a9e2cb52243c79a5dd55c607 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Thu, 23 Jul 2026 01:00:12 +0530 Subject: [PATCH 3/4] test(db): cover removeUserCanAuthorise lastModified bump NeDB adaptor already bumps lastModified on all user-role mutations; extend #1486 date-field tests to assert removeUserCanAuthorise preserves dateCreated while advancing lastModified. --- test/db/repo.date-fields.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/db/repo.date-fields.test.ts b/test/db/repo.date-fields.test.ts index 44441eced..82354ddfb 100644 --- a/test/db/repo.date-fields.test.ts +++ b/test/db/repo.date-fields.test.ts @@ -81,6 +81,35 @@ describe('Repo dateCreated / lastModified (#1486)', () => { expect(updatedDoc!.users.canPush).toContain('alice'); }); + it('removeUserCanAuthorise bumps lastModified but leaves dateCreated unchanged', async () => { + const existing: Repo = { + project: 'finos', + name: 'sample', + url: 'https://github.com/finos/sample.git', + users: { canPush: [], canAuthorise: ['bob'] }, + dateCreated: '2020-01-01T00:00:00.000Z', + lastModified: '2020-01-01T00:00:00.000Z', + _id: 'abc', + }; + + vi.spyOn(repoModule.db, 'findOne').mockImplementation((_: unknown, cb: any) => + cb(null, { ...existing }), + ); + let updatedDoc: Repo | null = null; + vi.spyOn(repoModule.db, 'update').mockImplementation( + (_q: unknown, doc: any, _o: unknown, cb: any) => { + updatedDoc = doc; + cb(null, 1); + }, + ); + + await repoModule.removeUserCanAuthorise('abc', 'bob'); + + expect(updatedDoc!.dateCreated).toBe('2020-01-01T00:00:00.000Z'); + expect(updatedDoc!.lastModified).not.toBe('2020-01-01T00:00:00.000Z'); + expect(updatedDoc!.users.canAuthorise).not.toContain('bob'); + }); + it('sort by dateCreated orders oldest → newest (asc)', () => { const repos: Pick[] = [ { name: 'zeta', dateCreated: '2024-06-01T00:00:00.000Z' }, From fa86d4268594e21823729b58a73dda28cd766ab4 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur Date: Tue, 28 Jul 2026 15:31:31 +0530 Subject: [PATCH 4/4] docs(db): note deferred Repo date backfill migration Record that existing-record backfill stays out of startup and belongs in a follow-up versioned migration using Mongo $toDate on _id. --- src/db/types.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/db/types.ts b/src/db/types.ts index d1d7662e6..8624ae196 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -58,9 +58,18 @@ export class Repo { name: string; url: string; users: { canPush: string[]; canAuthorise: string[] }; - /** ISO-8601; set on create, never overwritten thereafter */ + /** + * ISO-8601; set on create, never overwritten thereafter. + * Existing repos missing this field are intentionally left unset here — + * backfill belongs in a follow-up versioned migration (Mongo: prefer + * `$toDate: "$_id"` over an epoch default). Do not reintroduce startup + * one-off backfills. + */ dateCreated?: string; - /** ISO-8601; set on create and bumped on repo metadata mutations */ + /** + * ISO-8601; set on create and bumped on repo metadata mutations. + * Same migration note as {@link Repo.dateCreated}. + */ lastModified?: string; _id?: string;