Skip to content

Commit 83377fe

Browse files
authored
Merge pull request #1582 from dcoric/feat/postgres-data-migration
feat: data migration from mongo/fs to the PostgreSQL sink
2 parents 864be89 + fbb56bb commit 83377fe

10 files changed

Lines changed: 888 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"scripts": {
5555
"cli": "tsx ./packages/git-proxy-cli/index.ts",
5656
"cli:js": "node ./packages/git-proxy-cli/dist/index.js",
57+
"migrate:postgres": "tsx scripts/migrate-to-postgres.ts",
5758
"client": "vite --config vite.config.ts",
5859
"clientinstall": "npm install --prefix client",
5960
"server": "cross-env ALLOWED_ORIGINS=* tsx index.ts",

scripts/migrate-to-postgres.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env tsx
2+
3+
/**
4+
* Copyright 2026 GitProxy Contributors
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
import yargs from 'yargs';
20+
import { hideBin } from 'yargs/helpers';
21+
22+
import { getDatabase } from '../src/config';
23+
import * as postgres from '../src/db/postgres';
24+
import { resetConnection } from '../src/db/postgres/helper';
25+
import { migrate, MigrationSource } from '../src/db/postgres/migrate';
26+
import { createFileSource } from '../src/db/postgres/migrateFileSource';
27+
import { createMongoSource } from '../src/db/postgres/migrateMongoSource';
28+
29+
const argv = yargs(hideBin(process.argv))
30+
.usage('Usage: $0 --from <mongo|fs> [options]')
31+
.option('from', {
32+
choices: ['mongo', 'fs'] as const,
33+
demandOption: true,
34+
describe: 'Source backend to migrate from',
35+
})
36+
.option('mongoUrl', {
37+
type: 'string',
38+
describe: 'MongoDB connection string (required when --from mongo)',
39+
})
40+
.option('dataDir', {
41+
type: 'string',
42+
describe: 'NeDB data directory (defaults to ./.data/db) when --from fs',
43+
})
44+
.strict()
45+
.parseSync();
46+
47+
const buildSource = async (): Promise<MigrationSource> => {
48+
if (argv.from === 'mongo') {
49+
if (!argv.mongoUrl) {
50+
throw new Error('--mongoUrl is required when --from mongo');
51+
}
52+
return createMongoSource(argv.mongoUrl);
53+
}
54+
return createFileSource(argv.dataDir);
55+
};
56+
57+
const main = async (): Promise<void> => {
58+
// The destination is the active sink, so it must be postgres. Reading the
59+
// source is independent (its own driver), so the two never clash.
60+
const db = getDatabase();
61+
if (db.type !== 'postgres') {
62+
throw new Error(
63+
`The active sink is "${db.type}", but this migration writes to postgres. ` +
64+
'Enable the postgres sink (with its connectionString or ' +
65+
'GIT_PROXY_POSTGRES_CONNECTION_STRING) before running this.',
66+
);
67+
}
68+
69+
const source = await buildSource();
70+
try {
71+
const summary = await migrate(source, postgres, { log: (message) => console.log(message) });
72+
console.log('Migration complete:');
73+
console.log(` users: ${summary.users.imported} imported, ${summary.users.skipped} skipped`);
74+
console.log(` repos: ${summary.repos.imported} imported, ${summary.repos.skipped} skipped`);
75+
console.log(` pushes: ${summary.pushes.imported} imported`);
76+
} finally {
77+
await source.close();
78+
// Close the destination pool too, or its open handles keep the process
79+
// alive after the summary prints.
80+
await resetConnection();
81+
}
82+
};
83+
84+
main().catch((err) => {
85+
console.error(`Migration failed: ${err instanceof Error ? err.message : err}`);
86+
process.exit(1);
87+
});

src/db/postgres/migrate.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Action } from '../../proxy/actions';
18+
import { Repo, User } from '../types';
19+
20+
/**
21+
* A read-only view over a backend (mongo or fs) that data is migrated *from*.
22+
* Implementations own their own connection and must be closed by the caller.
23+
*/
24+
export const DEFAULT_PUSH_BATCH_SIZE = 500;
25+
26+
export interface MigrationSource {
27+
getUsers(): Promise<User[]>;
28+
getRepos(): Promise<Repo[]>;
29+
/**
30+
* Stream pushes in batches of at most `batchSize`. Production datasets can
31+
* hold tens of thousands of large push documents, so sources must not
32+
* require the whole table in memory at once.
33+
*/
34+
getPushBatches(batchSize: number): AsyncIterable<Action[]>;
35+
close(): Promise<void>;
36+
}
37+
38+
/**
39+
* The subset of the Postgres adapter used to write migrated records. The
40+
* adapter module satisfies this shape directly, so the CLI can pass it as-is.
41+
*/
42+
export interface MigrationDestination {
43+
findUser(username: string): Promise<User | null>;
44+
findUserByEmail(email: string): Promise<User | null>;
45+
createUser(user: User): Promise<void>;
46+
getRepoByUrl(url: string): Promise<Repo | null>;
47+
createRepo(repo: Repo): Promise<Repo>;
48+
writeAudit(action: Action): Promise<void>;
49+
}
50+
51+
export interface MigrationSummary {
52+
users: { imported: number; skipped: number };
53+
repos: { imported: number; skipped: number };
54+
pushes: { imported: number };
55+
}
56+
57+
export interface MigrateOptions {
58+
/** Receives human-readable progress lines. Defaults to a no-op. */
59+
log?: (message: string) => void;
60+
/** Maximum pushes fetched and written per batch. Defaults to 500. */
61+
pushBatchSize?: number;
62+
}
63+
64+
/**
65+
* Copy users, repos and pushes from `source` into the Postgres `destination`.
66+
*
67+
* Idempotent and re-runnable: users and repos that already exist (matched by
68+
* username/email and URL respectively) are skipped, and pushes are upserted by
69+
* their stable string id. Record `_id`s are intentionally NOT carried over —
70+
* Postgres assigns fresh UUIDs; push ids (TEXT) are preserved by the upsert.
71+
*/
72+
export const migrate = async (
73+
source: MigrationSource,
74+
destination: MigrationDestination,
75+
options: MigrateOptions = {},
76+
): Promise<MigrationSummary> => {
77+
const log = options.log ?? (() => undefined);
78+
const summary: MigrationSummary = {
79+
users: { imported: 0, skipped: 0 },
80+
repos: { imported: 0, skipped: 0 },
81+
pushes: { imported: 0 },
82+
};
83+
84+
const users = await source.getUsers();
85+
log(`Migrating ${users.length} user(s)...`);
86+
for (const user of users) {
87+
const existing =
88+
(await destination.findUser(user.username)) ||
89+
(user.email ? await destination.findUserByEmail(user.email) : null);
90+
if (existing) {
91+
summary.users.skipped++;
92+
continue;
93+
}
94+
// Legacy documents can lack optional fields the writers dereference:
95+
// users synced from AD may have no email (the mail attribute is
96+
// optional) or gitAccount. Default them like the mongo upsert path does.
97+
await destination.createUser({
98+
...user,
99+
email: user.email ?? '',
100+
gitAccount: user.gitAccount ?? '',
101+
});
102+
summary.users.imported++;
103+
}
104+
105+
const repos = await source.getRepos();
106+
log(`Migrating ${repos.length} repo(s)...`);
107+
for (const repo of repos) {
108+
if (await destination.getRepoByUrl(repo.url)) {
109+
summary.repos.skipped++;
110+
continue;
111+
}
112+
await destination.createRepo(repo);
113+
summary.repos.imported++;
114+
}
115+
116+
const batchSize = options.pushBatchSize ?? DEFAULT_PUSH_BATCH_SIZE;
117+
log('Migrating pushes...');
118+
for await (const batch of source.getPushBatches(batchSize)) {
119+
for (const push of batch) {
120+
await destination.writeAudit(push);
121+
summary.pushes.imported++;
122+
}
123+
log(` ${summary.pushes.imported} push(es) migrated`);
124+
}
125+
126+
return summary;
127+
};
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import fs from 'fs';
18+
import path from 'path';
19+
20+
import Datastore from '@seald-io/nedb';
21+
22+
import { Action } from '../../proxy/actions';
23+
import { toClass } from '../helper';
24+
import { Repo, User } from '../types';
25+
import { MigrationSource } from './migrate';
26+
27+
// Where the `fs` sink keeps its NeDB datastores.
28+
const DEFAULT_DATA_DIR = './.data/db';
29+
30+
/**
31+
* Build a read-only {@link MigrationSource} backed by the NeDB datastores the
32+
* `fs` sink writes. `dataDir` defaults to the location the sink uses
33+
* (`./.data/db`). Record `_id`s are ignored by the Postgres writers, which
34+
* assign fresh UUIDs.
35+
*/
36+
const DATASTORE_FILES = ['users.db', 'repos.db', 'pushes.db'];
37+
38+
interface LazyStore {
39+
store: Datastore;
40+
ready: () => Promise<void>;
41+
}
42+
43+
export const createFileSource = (dataDir: string = DEFAULT_DATA_DIR): MigrationSource => {
44+
// Fail fast on a wrong path rather than reporting a legitimately empty
45+
// backend: a missing directory, or one containing none of the fs sink's
46+
// datastores, is a misconfiguration, while an existing-but-empty datastore
47+
// is a real (empty) backend.
48+
if (!fs.existsSync(dataDir)) {
49+
throw new Error(`fs sink data directory does not exist: ${dataDir}`);
50+
}
51+
if (!DATASTORE_FILES.some((file) => fs.existsSync(path.join(dataDir, file)))) {
52+
throw new Error(`No fs sink datastores (${DATASTORE_FILES.join(', ')}) found in: ${dataDir}`);
53+
}
54+
55+
// Loading is explicit (no autoload) so a corrupt datastore surfaces as a
56+
// clear error instead of being silently treated as empty.
57+
const load = (file: string): LazyStore => {
58+
const filename = path.join(dataDir, file);
59+
const store = new Datastore({ filename });
60+
let loading: Promise<void> | undefined;
61+
const ready = () =>
62+
(loading ??= store.loadDatabaseAsync().catch((err: unknown) => {
63+
throw new Error(
64+
`Failed to load ${filename}: ${err instanceof Error ? err.message : String(err)}`,
65+
);
66+
}));
67+
return { store, ready };
68+
};
69+
70+
const users = load('users.db');
71+
const repos = load('repos.db');
72+
const pushes = load('pushes.db');
73+
74+
const readAll = async <T>({ store, ready }: LazyStore, proto: object): Promise<T[]> => {
75+
await ready();
76+
const docs = await store.findAsync<Record<string, unknown>>({});
77+
return docs.map((doc) => toClass(doc, proto) as T);
78+
};
79+
80+
// NeDB keeps the whole datastore in memory regardless, so batching here only
81+
// shapes the write side to match the MigrationSource contract.
82+
const getPushBatches = (batchSize: number): AsyncIterable<Action[]> => ({
83+
async *[Symbol.asyncIterator]() {
84+
const all = await readAll<Action>(pushes, Action.prototype);
85+
for (let i = 0; i < all.length; i += batchSize) {
86+
yield all.slice(i, i + batchSize);
87+
}
88+
},
89+
});
90+
91+
return {
92+
getUsers: () => readAll<User>(users, User.prototype),
93+
getRepos: () => readAll<Repo>(repos, Repo.prototype),
94+
getPushBatches,
95+
close: () => Promise.resolve(),
96+
};
97+
};
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { MongoClient, MongoClientOptions } from 'mongodb';
18+
19+
import { Action } from '../../proxy/actions';
20+
import { toClass } from '../helper';
21+
import { Repo, User } from '../types';
22+
import { MigrationSource } from './migrate';
23+
24+
/**
25+
* Build a read-only {@link MigrationSource} backed by a MongoDB instance.
26+
*
27+
* The connection is explicit (not the configured sink) so the importer can read
28+
* the legacy backend while the active sink points at the Postgres destination.
29+
* The caller owns the lifecycle and must `close()` the source when done.
30+
*/
31+
export const createMongoSource = async (
32+
connectionString: string,
33+
options: MongoClientOptions = {},
34+
): Promise<MigrationSource> => {
35+
const client = new MongoClient(connectionString, options);
36+
await client.connect();
37+
const db = client.db();
38+
39+
const readAll = async <T>(collection: string, proto: object): Promise<T[]> => {
40+
const docs = await db.collection(collection).find().toArray();
41+
// toClass drops mongo class identity; the ObjectId `_id` it carries through
42+
// is ignored by the Postgres writers, which assign fresh UUIDs.
43+
return docs.map((doc) => toClass(doc, proto) as T);
44+
};
45+
46+
// Pushes are streamed through a cursor rather than materialised: production
47+
// tables can hold tens of thousands of documents that each carry a full
48+
// diff, so a single toArray() would hold the entire table in memory.
49+
const getPushBatches = (batchSize: number): AsyncIterable<Action[]> => ({
50+
async *[Symbol.asyncIterator]() {
51+
const cursor = db.collection('pushes').find().batchSize(batchSize);
52+
let batch: Action[] = [];
53+
for await (const doc of cursor) {
54+
batch.push(toClass(doc, Action.prototype) as Action);
55+
if (batch.length >= batchSize) {
56+
yield batch;
57+
batch = [];
58+
}
59+
}
60+
if (batch.length > 0) {
61+
yield batch;
62+
}
63+
},
64+
});
65+
66+
return {
67+
getUsers: () => readAll<User>('users', User.prototype),
68+
getRepos: () => readAll<Repo>('repos', Repo.prototype),
69+
getPushBatches,
70+
close: () => client.close(),
71+
};
72+
};

0 commit comments

Comments
 (0)