Skip to content
5 changes: 5 additions & 0 deletions src/db/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ import * as users from './users';
import * as repo from './repo';
import * as pushes from './pushes';
import * as helper from './helper';
import * as migrations from './migrations';

export const { getSessionStore } = helper;

export const { getAppliedMigrations, recordMigration, unrecordMigration, deriveCreatedAt } =
migrations;

export const { getPushes, writeAudit, getPush, deletePush, authorise, cancel, reject } = pushes;

export const {
Expand All @@ -29,6 +33,7 @@ export const {
getRepoByUrl,
getRepoById,
createRepo,
updateRepo,
addUserCanPush,
addUserCanAuthorise,
removeUserCanPush,
Expand Down
70 changes: 70 additions & 0 deletions src/db/file/migrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* 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 Datastore from '@seald-io/nedb';

const COMPACTION_INTERVAL = 1000 * 60 * 60 * 24; // once per day

// export for testing purposes
export let db: Datastore;
if (process.env.NODE_ENV === 'test') {
db = new Datastore({ inMemoryOnly: true, autoload: true });
} else {
db = new Datastore({ filename: './.data/db/migrations.db', autoload: true });
}
db.ensureIndex({ fieldName: 'id', unique: true });
db.setAutocompactionInterval(COMPACTION_INTERVAL);

export const deriveCreatedAt = (): string | undefined => undefined;

export const getAppliedMigrations = (): Promise<string[]> => {
return new Promise<string[]>((resolve, reject) => {
db.find({}, (err: Error | null, docs: { id: string }[]) => {
/* istanbul ignore if */
if (err) {
reject(err);
} else {
resolve(docs.map((doc) => doc.id));
}
});
});
};

export const recordMigration = (id: string): Promise<void> => {
return new Promise<void>((resolve, reject) => {
db.update({ id }, { id }, { upsert: true }, (err) => {
/* istanbul ignore if */
if (err) {
reject(err);
} else {
resolve();
}
});
});
};

export const unrecordMigration = (id: string): Promise<void> => {
return new Promise<void>((resolve, reject) => {
db.remove({ id }, {}, (err) => {
/* istanbul ignore if */
if (err) {
reject(err);
} else {
resolve();
}
});
});
};
28 changes: 28 additions & 0 deletions src/db/file/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,34 @@ export const createRepo = async (repo: Repo): Promise<Repo> => {
});
};

export const updateRepo = async (repo: Partial<Repo>): Promise<void> => {
const { _id, ...fields } = repo;
const set: Record<string, unknown> = {};
const unset: Record<string, unknown> = {};
for (const [key, value] of Object.entries(fields)) {
if (value === undefined) unset[key] = true;
else set[key] = value;
}
const modifier: Record<string, unknown> = {};
if (Object.keys(set).length > 0) modifier.$set = set;
if (Object.keys(unset).length > 0) modifier.$unset = unset;

return new Promise<void>((resolve, reject) => {
if (Object.keys(modifier).length === 0) {
resolve();
return;
}
db.update({ _id: _id }, modifier, { multi: false, upsert: false }, (err) => {
/* istanbul ignore if */
if (err) {
reject(err);
} else {
resolve();
}
});
});
};

export const addUserCanPush = async (_id: string, user: string): Promise<void> => {
user = user.toLowerCase();
const repo = await getRepoById(_id);
Expand Down
3 changes: 3 additions & 0 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import MongoDBStore from 'connect-mongo';
import { CompletedAttestation, Rejection } from '../proxy/processors/types';
import { processGitUrl } from '../proxy/routes/helper';
import { initializeFolders } from './file/helper';
import { runMigrations as applyMigrations } from './migrations';
import { migrations } from './migrations/registry';

let _sink: Sink | null = null;

Expand Down Expand Up @@ -178,6 +180,7 @@ export const canUserCancelPush = async (id: string, user: string) => {
}
};

export const runMigrations = (): Promise<void> => applyMigrations(start(), migrations);
export const getSessionStore = (): MongoDBStore | undefined => start().getSessionStore();
export const getPushes = (query: Partial<PushQuery>): Promise<Action[]> => start().getPushes(query);
export const writeAudit = (action: Action): Promise<void> => start().writeAudit(action);
Expand Down
61 changes: 61 additions & 0 deletions src/db/migrations/example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* 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.
*/

// Template for a real migration. Copy this file, give it a timestamped id, and
// add it to registry.ts to make it run. It is intentionally NOT registered.
//
// It shows the pattern: read through the sink, use a backend capability
// (deriveCreatedAt) that each backend answers as best it can, write through the
// sink. Backfilling dateCreated/lastModified on repos is the worked example.

import type { Repo } from '../types';
import type { Migration } from './index';

export const exampleMigration: Migration = {
id: '20260728-example-add-repo-timestamps',

up: async (sink) => {
const repos = await sink.getRepos();
for (const repo of repos) {
const record = repo as unknown as Record<string, unknown>;
if (record.dateCreated || !repo._id) {
continue;
}
const created = sink.deriveCreatedAt(repo._id) ?? new Date().toISOString();
// cast: dateCreated/lastModified are new fields not yet on the Repo type
await sink.updateRepo({
_id: repo._id,
dateCreated: created,
lastModified: created,
} as Partial<Repo>);
}
},

down: async (sink) => {
const repos = await sink.getRepos();
for (const repo of repos) {
if (!repo._id) {
continue;
}

await sink.updateRepo({
_id: repo._id,
dateCreated: undefined,
lastModified: undefined,
} as Partial<Repo>);
}
},
};
70 changes: 70 additions & 0 deletions src/db/migrations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* 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 { Sink } from '../types';

export interface Migration {
id: string;
up: (sink: Sink) => Promise<void>;
down?: (sink: Sink) => Promise<void>;
}

const byId = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);

const assertUniqueIds = (migrations: Migration[]): void => {
const seen = new Set<string>();
for (const { id } of migrations) {
if (seen.has(id)) {
throw new Error(`Duplicate migration id: ${id}`);
}
seen.add(id);
}
};

export const runMigrations = async (sink: Sink, migrations: Migration[]): Promise<void> => {
assertUniqueIds(migrations);
const applied = new Set(await sink.getAppliedMigrations());
const pending = migrations.filter((m) => !applied.has(m.id)).sort((a, b) => byId(a.id, b.id));
for (const migration of pending) {
await migration.up(sink);
// record per-migration, not in one batch, so an interrupted run resumes cleanly
await sink.recordMigration(migration.id);
}
};

export const rollbackMigrations = async (
sink: Sink,
migrations: Migration[],
ids: string[],
): Promise<void> => {
const applied = new Set(await sink.getAppliedMigrations());
const byIdMap = new Map(migrations.map((m) => [m.id, m]));
const ordered = ids
.filter((id) => applied.has(id))
.sort(byId)
.reverse();
for (const id of ordered) {
const migration = byIdMap.get(id);
if (!migration) {
throw new Error(`Cannot roll back unknown migration: ${id}`);
}
if (!migration.down) {
throw new Error(`Migration ${id} is irreversible (no down())`);
}
await migration.down(sink);
await sink.unrecordMigration(id);
}
};
19 changes: 19 additions & 0 deletions src/db/migrations/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* 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 type { Migration } from './index';

export const migrations: Migration[] = [];
5 changes: 5 additions & 0 deletions src/db/mongo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ import * as helper from './helper';
import * as pushes from './pushes';
import * as repo from './repo';
import * as users from './users';
import * as migrations from './migrations';

export const { getSessionStore } = helper;

export const { getAppliedMigrations, recordMigration, unrecordMigration, deriveCreatedAt } =
migrations;

export const { getPushes, writeAudit, getPush, deletePush, authorise, cancel, reject } = pushes;

export const {
Expand All @@ -29,6 +33,7 @@ export const {
getRepoByUrl,
getRepoById,
createRepo,
updateRepo,
addUserCanPush,
addUserCanAuthorise,
removeUserCanPush,
Expand Down
44 changes: 44 additions & 0 deletions src/db/mongo/migrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* 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 { ObjectId } from 'mongodb';
import { connect } from './helper';

const collectionName = 'migrations';

export const deriveCreatedAt = (id: string): string | undefined => {
try {
return new ObjectId(id).getTimestamp().toISOString();
} catch {
return undefined;
}
};

export const getAppliedMigrations = async (): Promise<string[]> => {
const collection = await connect(collectionName);
const docs = await collection.find({}).toArray();
return docs.map((doc) => doc.id as string);
};

export const recordMigration = async (id: string): Promise<void> => {
const collection = await connect(collectionName);
await collection.updateOne({ id }, { $set: { id } }, { upsert: true });
};

export const unrecordMigration = async (id: string): Promise<void> => {
const collection = await connect(collectionName);
await collection.deleteOne({ id });
};
17 changes: 17 additions & 0 deletions src/db/mongo/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ export const createRepo = async (repo: Repo): Promise<Repo> => {
return repo;
};

export const updateRepo = async (repo: Partial<Repo>): Promise<void> => {
const { _id, ...fields } = repo;
const collection = await connect(collectionName);
const set: Record<string, unknown> = {};
const unset: Record<string, unknown> = {};
for (const [key, value] of Object.entries(fields)) {
if (value === undefined) unset[key] = '';
else set[key] = value;
}
const update: Record<string, unknown> = {};
if (Object.keys(set).length > 0) update.$set = set;
if (Object.keys(unset).length > 0) update.$unset = unset;
if (Object.keys(update).length > 0) {
await collection.updateOne({ _id: new ObjectId(_id as string) }, update);
}
};

export const addUserCanPush = async (_id: string, user: string): Promise<void> => {
user = user.toLowerCase();
const collection = await connect(collectionName);
Expand Down
Loading
Loading