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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ jobs:
- name: Install dependencies
run: npm install

- name: Check migration round trip
run: npm run test:migrations

- name: Run tests
run: npm test

Expand Down
22 changes: 18 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ Contributors are very welcome. This project is a local-first aggregator for free

```bash
npm install
npm run dev # server on :3001, dashboard on :5173, both with HMR
npm test # server vitest; also runs client tests if present
npm run build # compile server and dashboard
npm run dev # server on :3001, dashboard on :5173, both with HMR
npm run db:migration:up # apply all the migrations to your local database
npm test # server vitest; also runs client tests if present
npm run build # compile server and dashboard
```

Every PR should:
Expand All @@ -18,6 +19,19 @@ Every PR should:
- Stay scoped to one change. Smaller PRs get reviewed and merged faster.
- Avoid adding paid or card-gated services. This catalog only lists tiers that are genuinely free to start using without a credit card.

## Database migrations

Schema changes must use file-per-migration files under
`server/src/db/migrations/`. Do not edit previously applied migration files.

Control database migrations with ([db/README.md](server/src/db/README.md)):

```bash
npm run db:migration:create --name=add_embedding_index
npm run db:migration:up
npm run db:migration:down
```

## AI and LLM-assisted contributions

LLM-assisted PRs are welcome. A lot of this codebase is itself built that way, so there is no stigma here. The bar is the same as for any other PR: you are responsible for what you submit.
Expand Down Expand Up @@ -46,4 +60,4 @@ Some useful fixes and experiments live in community forks and branches. If you a
- `fix-119-atomic-ratelimits` — atomic SQLite `BEGIN IMMEDIATE` transactions to fix rate-limit race conditions.
- `feature-122-auto-routing` — per-request `smart` / `fast` / `cheap` routing strategies.

If you port one of these into a PR, credit the original author in the PR description so they land in the Contributors list.
If you port one of these into a PR, credit the original author in the PR description so they land in the Contributors list.
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ Stacking free tiers has real trade-offs. Be honest with yourself about them:

Contributors very welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for the dev loop, PR expectations, and the policy on AI/LLM-assisted contributions (short version: welcome, same quality bar as any other PR). Good first PRs:

- **Add a provider** — copy `server/src/providers/openai-compat.ts` as a template, wire it into `server/src/providers/index.ts`, seed its models in `server/src/db/index.ts`, add a test in `server/src/__tests__/providers/`.
- **Add a provider** — copy `server/src/providers/openai-compat.ts` as a template, wire it into `server/src/providers/index.ts`, add its built-in models with a migration, add a test in `server/src/__tests__/providers/`.
- **Add an endpoint** — images, moderations, audio. The provider base class can grow new methods; adapters declare which they support.
- **Improve the router** — cost-aware routing (cheapest-healthy-fastest tradeoffs), better latency-weighted priority, regional pinning.
- **Dashboard polish** — charts on the Analytics page, key rotation UX, batch import of keys from `.env`.
Expand All @@ -515,7 +515,17 @@ npm test # server vitest; also runs client tests if the workspace adds t
npm run build # compile server and dashboard
```

PRs should include a test, keep the existing test suite green, and match the `.editorconfig` / tsconfig defaults already in the repo. Issues and discussions are open.
PRs should include a test, keep the existing test suite green, and match the `.editorconfig` / tsconfig defaults already in the repo. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full contributor workflow.

### Database Migrations

In local development, apply pending migrations with:

```bash
NODE_ENV=development npm run db:migration:up
```

See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full migration CLI and workflow.

### Contributors

Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@
"test": "npm run test -w server && npm run test -w client --if-present",
"build": "npm run build -w server && npm run build -w client",
"build:server": "npm run build -w server",
"test:migrations": "npm run test:migrations -w server",
"desktop:dev": "npm run build -w client && npm --prefix desktop run dev",
"desktop:dist": "npm run build -w client && npm --prefix desktop run dist",
"desktop:dist:win": "npm run build -w client && npm --prefix desktop run dist:win"
"desktop:dist:win": "npm run build -w client && npm --prefix desktop run dist:win",
"db:migration:up": "npm run db:migration:up -w server",
"db:migration:down": "npm run db:migration:down -w server",
"db:migration:fresh": "npm run db:migration:fresh -w server",
"db:migration:status": "npm run db:migration:status -w server",
"db:migration:create": "npm run db:migration:create -w server"
},
"devDependencies": {
"concurrently": "^9.1.2"
Expand Down
8 changes: 7 additions & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@
"start": "node dist/index.js",
"test": "vitest run --pool=forks --fileParallelism=false",
"test:watch": "vitest",
"export-catalog": "tsx src/scripts/export-catalog.ts"
"test:migrations": "vitest run --pool=forks --fileParallelism=false src/__tests__/db/migrate/roundtrip.test.ts",
"export-catalog": "tsx src/scripts/export-catalog.ts",
"db:migration:up": "tsx src/db/migrate/cli.ts up",
"db:migration:down": "tsx src/db/migrate/cli.ts down",
"db:migration:fresh": "tsx src/db/migrate/cli.ts fresh",
"db:migration:status": "tsx src/db/migrate/cli.ts status",
"db:migration:create": "tsx src/db/migrate/cli.ts create"
},
"dependencies": {
"@freellmapi/shared": "*",
Expand Down
193 changes: 193 additions & 0 deletions server/src/__tests__/db/migrate/roundtrip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { connectDb } from '../../../db/index.js';
import { getMigrationStatuses, runMigrations } from '../../../db/migrate/runner.js';
import { up as runLegacyBaseline } from '../../../db/migrations/20260101_000000_legacy_baseline.js';

const LEGACY_BASELINE_FILENAME = '20260101_000000_legacy_baseline.ts';

interface SchemaRow {
type: string;
name: string;
tbl_name: string;
sql: string | null;
}

interface DatabaseSnapshot {
schema: SchemaRow[];
rows: Record<string, unknown[]>;
}

describe('migration round trip', () => {
it('connectDb opens a connection without applying migrations', () => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'test';
const db = connectDb(':memory:');

try {
expect(hasTable(db, 'models')).toBe(false);
expect(hasTable(db, 'migrations')).toBe(false);
} finally {
db.close();
if (originalNodeEnv === undefined) {
delete process.env.NODE_ENV;
} else {
process.env.NODE_ENV = originalNodeEnv;
}
}
});

it('runs the legacy baseline against existing legacy DBs so rebased legacy changes apply', async () => {
const db = new Database(':memory:');

try {
runLegacyBaseline(db);
db.prepare(`
UPDATE models
SET enabled = 1
WHERE platform = 'opencode'
AND model_id IN ('nemotron-3-super-free', 'minimax-m3-free')
`).run();

expect(getEnabledZenDeadPromoCount(db)).toBe(2);

await runMigrations(db, 'up');

expect(getEnabledZenDeadPromoCount(db)).toBe(0);
expect(getAppliedMigrationNames(db)).toEqual([LEGACY_BASELINE_FILENAME]);
} finally {
db.close();
}
});

it('runs all migrations up, down to baseline, then up to the same schema', async () => {
const db = new Database(':memory:');

try {
await runMigrations(db, 'up');
expect(getPendingMigrationNames(db)).toEqual([]);

const fullState = snapshotAppState(db);
await runDownToBaseline(db);

expect(getAppliedMigrationNames(db)).toEqual([LEGACY_BASELINE_FILENAME]);

await runMigrations(db, 'up');
expect(getPendingMigrationNames(db)).toEqual([]);
expect(snapshotAppState(db)).toEqual(fullState);
} finally {
db.close();
}
});
});

async function runDownToBaseline(db: Database.Database): Promise<void> {
while (getAppliedMigrationNames(db).length > 1) {
const migrationName = getLatestAppliedMigrationName(db);
const before = snapshotAppState(db);

await runMigrations(db, 'down');

expect(snapshotAppState(db), `${migrationName} down() must alter app DB state or throw irreversible`)
.not.toEqual(before);
}
}

function getLatestAppliedMigrationName(db: Database.Database): string {
const row = db.prepare(`
SELECT filename
FROM migrations
ORDER BY id DESC
LIMIT 1
`).get() as { filename: string } | undefined;

if (!row) throw new Error('No applied migrations found');
return row.filename;
}

function getAppliedMigrationNames(db: Database.Database): string[] {
return getMigrationStatuses(db)
.filter(status => status.status === 'applied')
.map(status => status.filename);
}

function getPendingMigrationNames(db: Database.Database): string[] {
return getMigrationStatuses(db)
.filter(status => status.status === 'pending')
.map(status => status.filename);
}

function getEnabledZenDeadPromoCount(db: Database.Database): number {
const row = db.prepare(`
SELECT COUNT(*) AS count
FROM models
WHERE platform = 'opencode'
AND model_id IN ('nemotron-3-super-free', 'minimax-m3-free')
AND enabled = 1
`).get() as { count: number };

return row.count;
}

function snapshotSchema(db: Database.Database): SchemaRow[] {
return db.prepare(`
SELECT type, name, tbl_name, sql
FROM sqlite_master
WHERE type IN ('index', 'table', 'trigger', 'view')
AND name NOT LIKE 'sqlite_%'
ORDER BY type, name
`).all() as SchemaRow[];
}

function snapshotAppState(db: Database.Database): DatabaseSnapshot {
const tableNames = getAppTableNames(db);
const rows: Record<string, unknown[]> = {};

for (const tableName of tableNames) {
rows[tableName] = snapshotTableRows(db, tableName);
}

return {
schema: snapshotSchema(db),
rows,
};
}

function getAppTableNames(db: Database.Database): string[] {
const rows = db.prepare(`
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name NOT LIKE 'sqlite_%'
AND name <> 'migrations'
ORDER BY name
`).all() as { name: string }[];

return rows.map(row => row.name);
}

function snapshotTableRows(db: Database.Database, tableName: string): unknown[] {
const columns = db.prepare(`PRAGMA table_info(${quoteIdentifier(tableName)})`).all() as { name: string }[];
const orderBy = columns.map(column => quoteIdentifier(column.name)).join(', ');

return db.prepare(`
SELECT *
FROM ${quoteIdentifier(tableName)}
ORDER BY ${orderBy}
`).all() as unknown[];
}

function hasTable(db: Database.Database, tableName: string): boolean {
const row = db.prepare(`
SELECT name
FROM sqlite_master
WHERE type = 'table'
AND name = ?
`).get(tableName);

return Boolean(row);
}

function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
Loading
Loading