Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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