mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(db): provide better-sqlite3 connection via DI token and complete DatabaseService surface
This commit is contained in:
@@ -167,3 +167,4 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };
|
export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner };
|
||||||
|
export type { TripAccess, PlaceWithTags };
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { db } from '../../db/database';
|
||||||
import { DatabaseService } from './database.service';
|
import { DatabaseService } from './database.service';
|
||||||
|
import { DATABASE_CONNECTION } from './database.tokens';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global so every migrated module can inject DatabaseService without re-importing.
|
* Global so every migrated module can inject DatabaseService without re-importing.
|
||||||
* Wraps the existing better-sqlite3 singleton (no new connection).
|
* DATABASE_CONNECTION resolves to the existing better-sqlite3 singleton Proxy
|
||||||
|
* (no new connection, single writer preserved); tests can override the token
|
||||||
|
* to hand the container a throwaway connection.
|
||||||
*/
|
*/
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [DatabaseService],
|
providers: [{ provide: DATABASE_CONNECTION, useFactory: () => db }, DatabaseService],
|
||||||
exports: [DatabaseService],
|
exports: [DatabaseService, DATABASE_CONNECTION],
|
||||||
})
|
})
|
||||||
export class DatabaseModule {}
|
export class DatabaseModule {}
|
||||||
|
|||||||
@@ -1,39 +1,62 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type Database from 'better-sqlite3';
|
import type Database from 'better-sqlite3';
|
||||||
import { db } from '../../db/database';
|
import { canAccessTrip, getPlaceWithTags, isOwner } from '../../db/database';
|
||||||
|
import type { PlaceWithTags, TripAccess } from '../../db/database';
|
||||||
|
import { DATABASE_CONNECTION } from './database.tokens';
|
||||||
|
|
||||||
|
export type { PlaceWithTags, TripAccess };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injectable wrapper around TREK's existing better-sqlite3 connection.
|
* Injectable wrapper around TREK's existing better-sqlite3 connection.
|
||||||
*
|
*
|
||||||
* `db` is a Proxy onto the singleton connection the legacy app already uses
|
* The injected connection is the Proxy onto the singleton the legacy app
|
||||||
* (WAL enabled), so Nest modules share the exact same connection — no second
|
* already uses (WAL enabled), so Nest modules share the exact same
|
||||||
* connection, no split state, single writer preserved.
|
* connection — no second connection, no split state, single writer preserved.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DatabaseService {
|
export class DatabaseService {
|
||||||
|
constructor(@Inject(DATABASE_CONNECTION) private readonly conn: Database.Database) {}
|
||||||
|
|
||||||
/** The shared better-sqlite3 connection (same singleton the legacy app uses). */
|
/** The shared better-sqlite3 connection (same singleton the legacy app uses). */
|
||||||
get connection(): Database.Database {
|
get connection(): Database.Database {
|
||||||
return db;
|
return this.conn;
|
||||||
}
|
}
|
||||||
|
|
||||||
prepare(sql: string): Database.Statement {
|
prepare(sql: string): Database.Statement {
|
||||||
return db.prepare(sql);
|
return this.conn.prepare(sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
get<T = unknown>(sql: string, ...params: unknown[]): T | undefined {
|
get<T = unknown>(sql: string, ...params: unknown[]): T | undefined {
|
||||||
return db.prepare(sql).get(...params) as T | undefined;
|
return this.conn.prepare(sql).get(...params) as T | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
all<T = unknown>(sql: string, ...params: unknown[]): T[] {
|
all<T = unknown>(sql: string, ...params: unknown[]): T[] {
|
||||||
return db.prepare(sql).all(...params) as T[];
|
return this.conn.prepare(sql).all(...params) as T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
run(sql: string, ...params: unknown[]): Database.RunResult {
|
run(sql: string, ...params: unknown[]): Database.RunResult {
|
||||||
return db.prepare(sql).run(...params);
|
return this.conn.prepare(sql).run(...params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run `fn` inside a synchronous better-sqlite3 transaction. */
|
/** Run `fn` inside a synchronous better-sqlite3 transaction. */
|
||||||
transaction<T>(fn: (conn: Database.Database) => T): T {
|
transaction<T>(fn: (conn: Database.Database) => T): T {
|
||||||
return db.transaction(() => fn(db))();
|
return this.conn.transaction(() => fn(this.conn))();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trip-access helpers delegate to the db/database exports (not this.conn):
|
||||||
|
// tests vi.mock that module and stub the helpers themselves, so the stubs
|
||||||
|
// must keep flowing through here.
|
||||||
|
|
||||||
|
/** Trip visible to the user (owner or member); undefined when no access. */
|
||||||
|
canAccessTrip(tripId: number | string, userId: number): TripAccess | undefined {
|
||||||
|
return canAccessTrip(tripId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
isOwner(tripId: number | string, userId: number): boolean {
|
||||||
|
return isOwner(tripId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaceWithTags(placeId: number | string): PlaceWithTags | null {
|
||||||
|
return getPlaceWithTags(placeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Injection token for the shared better-sqlite3 connection. Provided by
|
||||||
|
* DatabaseModule from the existing singleton Proxy in src/db/database.ts,
|
||||||
|
* so it stays valid across backup-restore/demo-reset connection swaps.
|
||||||
|
*/
|
||||||
|
export const DATABASE_CONNECTION = Symbol('DATABASE_CONNECTION');
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Locks in the delegation direction: DatabaseService's trip-access helpers
|
||||||
|
* must call the db/database exports (which e2e suites stub in their vi.mock
|
||||||
|
* factories), never reimplement the SQL against the injected connection.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { canAccessTrip, isOwner, getPlaceWithTags } = vi.hoisted(() => ({
|
||||||
|
canAccessTrip: vi.fn(() => ({ id: -1, user_id: -2, currency: 'XXX' })),
|
||||||
|
isOwner: vi.fn(() => true),
|
||||||
|
getPlaceWithTags: vi.fn(() => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../src/db/database', () => ({
|
||||||
|
db: {},
|
||||||
|
closeDb: () => {},
|
||||||
|
reinitialize: () => {},
|
||||||
|
canAccessTrip,
|
||||||
|
isOwner,
|
||||||
|
getPlaceWithTags,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||||
|
|
||||||
|
describe('DatabaseService (helper delegation)', () => {
|
||||||
|
it('routes trip-access helpers through the db/database exports', async () => {
|
||||||
|
const { db } = await import('../../../src/db/database');
|
||||||
|
const svc = new DatabaseService(db);
|
||||||
|
|
||||||
|
expect(svc.canAccessTrip(7, 8)).toEqual({ id: -1, user_id: -2, currency: 'XXX' });
|
||||||
|
expect(canAccessTrip).toHaveBeenCalledWith(7, 8);
|
||||||
|
|
||||||
|
expect(svc.isOwner(7, 8)).toBe(true);
|
||||||
|
expect(isOwner).toHaveBeenCalledWith(7, 8);
|
||||||
|
|
||||||
|
expect(svc.getPlaceWithTags(9)).toBeNull();
|
||||||
|
expect(getPlaceWithTags).toHaveBeenCalledWith(9);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,10 +3,11 @@
|
|||||||
* helper against the real connection so the typed query surface is covered.
|
* helper against the real connection so the typed query surface is covered.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { db } from '../../../src/db/database';
|
||||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||||
|
|
||||||
describe('DatabaseService (typed query helpers)', () => {
|
describe('DatabaseService (typed query helpers)', () => {
|
||||||
const svc = new DatabaseService();
|
const svc = new DatabaseService(db);
|
||||||
|
|
||||||
it('exposes the shared connection', () => {
|
it('exposes the shared connection', () => {
|
||||||
expect(typeof svc.connection.prepare).toBe('function');
|
expect(typeof svc.connection.prepare).toBe('function');
|
||||||
@@ -34,3 +35,40 @@ describe('DatabaseService (typed query helpers)', () => {
|
|||||||
svc.run('DROP TABLE _dbsvc_test');
|
svc.run('DROP TABLE _dbsvc_test');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DatabaseService (trip-access helpers)', () => {
|
||||||
|
const svc = new DatabaseService(db);
|
||||||
|
|
||||||
|
it('canAccessTrip / isOwner / getPlaceWithTags delegate to the shared helpers', () => {
|
||||||
|
svc.run(
|
||||||
|
"INSERT INTO users (id, username, email, password_hash) VALUES (900, 'dbsvc-owner', 'dbsvc-owner@x.test', 'x')"
|
||||||
|
);
|
||||||
|
svc.run(
|
||||||
|
"INSERT INTO users (id, username, email, password_hash) VALUES (901, 'dbsvc-member', 'dbsvc-member@x.test', 'x')"
|
||||||
|
);
|
||||||
|
svc.run("INSERT INTO trips (id, user_id, title) VALUES (900, 900, 'DbSvc Trip')");
|
||||||
|
svc.run("INSERT INTO trip_members (trip_id, user_id) VALUES (900, 901)");
|
||||||
|
|
||||||
|
expect(svc.canAccessTrip(900, 900)).toMatchObject({ id: 900, user_id: 900 });
|
||||||
|
expect(svc.canAccessTrip(900, 901)).toMatchObject({ id: 900 });
|
||||||
|
expect(svc.canAccessTrip(900, 999)).toBeUndefined();
|
||||||
|
|
||||||
|
expect(svc.isOwner(900, 900)).toBe(true);
|
||||||
|
expect(svc.isOwner(900, 901)).toBe(false);
|
||||||
|
|
||||||
|
expect(svc.getPlaceWithTags(999999)).toBeNull();
|
||||||
|
svc.run("INSERT INTO places (id, trip_id, name) VALUES (900, 900, 'DbSvc Place')");
|
||||||
|
expect(svc.getPlaceWithTags(900)).toMatchObject({
|
||||||
|
id: 900,
|
||||||
|
name: 'DbSvc Place',
|
||||||
|
category: null,
|
||||||
|
tags: [],
|
||||||
|
ratings: [],
|
||||||
|
rating_avg: null,
|
||||||
|
rating_count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
svc.run('DELETE FROM trips WHERE id = 900');
|
||||||
|
svc.run('DELETE FROM users WHERE id IN (900, 901)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { HttpException } from '@nestjs/common';
|
import { HttpException } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { db } from '../../../src/db/database';
|
||||||
import { AppModule } from '../../../src/nest/app.module';
|
import { AppModule } from '../../../src/nest/app.module';
|
||||||
import { HealthController } from '../../../src/nest/health/health.controller';
|
import { HealthController } from '../../../src/nest/health/health.controller';
|
||||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||||
@@ -33,7 +34,7 @@ describe('AdminGuard', () => {
|
|||||||
|
|
||||||
describe('DatabaseService (shared connection)', () => {
|
describe('DatabaseService (shared connection)', () => {
|
||||||
it('runs real queries against the existing SQLite connection', () => {
|
it('runs real queries against the existing SQLite connection', () => {
|
||||||
const svc = new DatabaseService();
|
const svc = new DatabaseService(db);
|
||||||
expect(svc.get('SELECT 1 AS one')).toEqual({ one: 1 });
|
expect(svc.get('SELECT 1 AS one')).toEqual({ one: 1 });
|
||||||
expect(svc.all('SELECT 1 AS one')).toEqual([{ one: 1 }]);
|
expect(svc.all('SELECT 1 AS one')).toEqual([{ one: 1 }]);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user