From d59cb2668d1373e6239879241baf8e3e45dcd22b Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 23 Apr 2026 16:54:01 +0000 Subject: [PATCH] feat(database): Add basic repository and transaction support --- .../common/database/drizzle/drizzleUtils.ts | 43 +++++++++++- .../common/database/drizzle/repository.ts | 65 +++++++++++++++++++ src/backend/tests/database/drizzle.test.ts | 40 +++++++++++- src/core/DataUtils.ts | 2 +- src/core/tests/utils/fixtures.ts | 3 +- 5 files changed, 149 insertions(+), 4 deletions(-) diff --git a/src/backend/common/database/drizzle/drizzleUtils.ts b/src/backend/common/database/drizzle/drizzleUtils.ts index 3b0e64ec..2cef680a 100644 --- a/src/backend/common/database/drizzle/drizzleUtils.ts +++ b/src/backend/common/database/drizzle/drizzleUtils.ts @@ -1,5 +1,6 @@ import { drizzle } from 'drizzle-orm/node-sqlite'; import { migrate } from 'drizzle-orm/node-sqlite/migrator'; +import { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"; import { sql as dsl } from 'drizzle-orm'; import * as fs from 'fs/promises'; import * as path from 'path'; @@ -85,4 +86,44 @@ export const migrateDb = async (db: ReturnType, opts: {parentLog } catch (e) { throw new Error('Failed to migrate database', { cause: e }); } -} \ No newline at end of file +} + + + +// cannot really use transactions right now because async isn't supporting for sqlite +// https://github.com/drizzle-team/drizzle-orm/issues/1472 +// https://github.com/drizzle-team/drizzle-orm/issues/2275 +// so use this workaround for now +// https://github.com/drizzle-team/drizzle-orm/issues/2275#issuecomment-2496503801 +let currentTransaction: null | Promise = null; +export const runTransaction = async < + T, + TQueryResult, + TSchema extends Record = Record +>( + db: BaseSQLiteDatabase<"sync", TQueryResult, TSchema>, + executor: () => Promise +) => { + while (currentTransaction !== null) { + await currentTransaction; + } + let resolve!: () => void; + currentTransaction = new Promise(_resolve => { + resolve = _resolve; + }); + try { + db.run(dsl.raw(`BEGIN`)) + + try { + const result = await executor(); + await db.run(dsl.raw(`COMMIT`)); + return result; + } catch (error) { + await db.run(dsl.raw(`ROLLBACK`)); + throw error; + } + } finally { + resolve(); + currentTransaction = null; + } +}; \ No newline at end of file diff --git a/src/backend/common/database/drizzle/repository.ts b/src/backend/common/database/drizzle/repository.ts index e69de29b..920ef1b9 100644 --- a/src/backend/common/database/drizzle/repository.ts +++ b/src/backend/common/database/drizzle/repository.ts @@ -0,0 +1,65 @@ +import { Logger, LoggerAppExtras } from "@foxxmd/logging"; +import { getDb, runTransaction } from "./drizzleUtils.js"; +import { loggerNoop } from "../../MaybeLogger.js"; +import { PlayObject } from "../../../../core/Atomic.js"; +import { generateInputEntity, generatePlayEntity, PlayEntityOpts } from "./entityUtils.js"; +import { PlayInputNew, playInputs, PlayNew, plays, PlaySelect } from "./schema/drizzlePlaysTable.js"; +import { MarkOptional, MarkRequired } from "ts-essentials"; +import { nanoid } from "nanoid"; + +export interface DrizzleRepositoryOpts { + logger?: Logger +} + +export type RepositoryCreatePlayOpts = PlayEntityOpts + & { + input: MarkOptional + } + & MarkRequired, 'componentId'>; +export class DrizzleRepository { + + logger: Logger; + db: ReturnType; + + constructor(db: ReturnType, opts: DrizzleRepositoryOpts = {}) { + this.db = db; + this.logger = opts.logger ?? loggerNoop; + } + + createPlays = async (entitiesOpts: RepositoryCreatePlayOpts[]) => { + + let playRows: PlaySelect[]; + + await runTransaction(this.db, async () => { + + const entitiesData = entitiesOpts.map((data) => { + const { + play, + input, + ...rest + } = data; + return generatePlayEntity(play, { ...rest}); + }); + + playRows = await this.db.insert(plays).values(entitiesData).returning(); + + const inputDatas = playRows.map((x, index) => { + const { + play, + input, + } = entitiesOpts[index]; + const { + play: inputPlay = play, + ...restInput + } = input; + + return generateInputEntity({ play: inputPlay, playId: x.id, ...restInput }); + }); + + const inputRow = await this.db.insert(playInputs).values(inputDatas); + + }); + + return playRows; + } +} \ No newline at end of file diff --git a/src/backend/tests/database/drizzle.test.ts b/src/backend/tests/database/drizzle.test.ts index ccfc09c5..18a9a70f 100644 --- a/src/backend/tests/database/drizzle.test.ts +++ b/src/backend/tests/database/drizzle.test.ts @@ -13,6 +13,10 @@ import * as fs from 'fs/promises'; import { projectDir } from '../../common/index.js'; import { DatabaseSync } from 'node:sqlite'; import { fixtureCreateComponent, fixtureCreateInput, fixtureCreatePlay } from '../utils/databaseFixtures.js'; +import { DrizzleRepository, RepositoryCreatePlayOpts } from '../../common/database/drizzle/repository.js'; +import { generateRandomObj } from '../../../core/tests/utils/fixtures.js'; +import { generateArray } from '../../../core/DataUtils.js'; +import { objectsEqual } from '../../utils/DataUtils.js'; // would be great to push migrations directly from schema but doesn't seem supported in newest beta // https://github.com/drizzle-team/drizzle-orm/discussions/4373 @@ -141,7 +145,7 @@ describe('Basic DB Operations', function () { const component = await db.insert(components).values(fixtureCreateComponent()).returning(); - const playRow = await db.insert(plays).values(fixtureCreatePlay({componentId: component[0].id})).returning(); + const playRow = await db.insert(plays).values(fixtureCreatePlay({ componentId: component[0].id })).returning(); const input = await db.insert(playInputs).values(fixtureCreateInput({ playId: playRow[0].id, @@ -184,3 +188,37 @@ describe('Basic DB Operations', function () { }); +describe('Repository Operations', function () { + + it('creates Plays and inputs', async function () { + + const db = getDb(':memory:'); + await migrateDb(db); + + const component = await db.insert(components).values(fixtureCreateComponent()).returning(); + + const repo = new DrizzleRepository(db); + + const numPlays = 3; + + const playData = generateArray(numPlays, () => ({ ...fixtureCreatePlay(), componentId: component[0].id, state: 'queued', input: { data: generateRandomObj(undefined, {allowUndefined: false}) } })) + + const rows = await repo.createPlays(playData); + expect(rows).length(numPlays); + const fullPlays = await db.query.plays.findMany({ + with: { + input: true + } + }); + fullPlays.forEach((play, index) => { + const ref = playData[index]; + + expect(play.play.data.track).eq(ref.play.data.track); + expect(play.input).to.not.undefined; + expect(objectsEqual(play.input.data, ref.input.data)).is.true; + }) + + }); + +}); + diff --git a/src/core/DataUtils.ts b/src/core/DataUtils.ts index 66ab88e6..64c4049d 100644 --- a/src/core/DataUtils.ts +++ b/src/core/DataUtils.ts @@ -82,7 +82,7 @@ export const formatNumber = (val: number | string, options?: numberFormatOptions return `${prefixStr}${localeString}${suffix}`; }; -export const generateArray = (size: number, gen: (index: number) => any) => { +export const generateArray = (size: number, gen: (index: number) => T): T[] => { return Array.from(Array(size), (v,k) => gen(k)); } diff --git a/src/core/tests/utils/fixtures.ts b/src/core/tests/utils/fixtures.ts index bd21c5ea..6545b9f3 100644 --- a/src/core/tests/utils/fixtures.ts +++ b/src/core/tests/utils/fixtures.ts @@ -244,13 +244,14 @@ export interface RandomObjOptions { maxKeyLength?: number keyCount?: number maxDepth?: number + allowUndefined?: boolean } const generateRandomVal = (depth: number = 0, opt: RandomObjOptions = {}, typeId?: number) => { const i = typeId ?? faker.number.int({ min: 1, max: depth > (opt.maxDepth ?? 3) ? 6 : 8 }); switch (i) { case 1: - return undefined; + return (opt.allowUndefined ?? true) ? undefined : null; case 2: return faker.datatype.boolean(); case 3: -- 2.51.2