feat(v2.3): 完成关系表写入闭环

This commit is contained in:
Script Generator
2026-07-03 13:28:59 +08:00
parent e9ff986bac
commit bba02775dc
17 changed files with 732 additions and 18 deletions

View File

@@ -1,4 +1,6 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
import { PrismaModule } from './prisma/prisma.module';
import { ProductModule } from './modules/product/product.module';
import { RequirementModule } from './modules/requirement/requirement.module';
@@ -11,6 +13,11 @@ import { V22QueryModule } from './modules/v22-query/v22-query.module';
@Module({
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, AiModule],
controllers: [],
providers: [],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: ApiTimingInterceptor,
},
],
})
export class AppModule {}

View File

@@ -0,0 +1,69 @@
import { lastValueFrom, of, throwError } from 'rxjs';
import { ApiTimingInterceptor, resolveApiSlowRequestThreshold } from './api-timing.interceptor';
describe('ApiTimingInterceptor', () => {
const originalEnv = process.env.API_SLOW_REQUEST_MS;
afterEach(() => {
process.env.API_SLOW_REQUEST_MS = originalEnv;
jest.restoreAllMocks();
});
it('uses a safe default threshold when env is missing or invalid', () => {
delete process.env.API_SLOW_REQUEST_MS;
expect(resolveApiSlowRequestThreshold()).toBe(1000);
process.env.API_SLOW_REQUEST_MS = 'not-a-number';
expect(resolveApiSlowRequestThreshold()).toBe(1000);
});
it('uses a positive configured threshold', () => {
process.env.API_SLOW_REQUEST_MS = '250';
expect(resolveApiSlowRequestThreshold()).toBe(250);
});
it('logs slow HTTP requests with method and url', async () => {
process.env.API_SLOW_REQUEST_MS = '10';
const interceptor = new ApiTimingInterceptor();
const warn = jest.fn();
(interceptor as any).logger.warn = warn;
jest.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(1025);
const context = {
switchToHttp: () => ({
getRequest: () => ({
method: 'PUT',
originalUrl: '/api/v1/data/dev-tasks',
}),
}),
};
const next = { handle: () => of({ ok: true }) };
await lastValueFrom(interceptor.intercept(context as any, next as any));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('PUT /api/v1/data/dev-tasks'));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('25ms'));
});
it('logs slow HTTP requests even when the handler fails', async () => {
process.env.API_SLOW_REQUEST_MS = '10';
const interceptor = new ApiTimingInterceptor();
const warn = jest.fn();
(interceptor as any).logger.warn = warn;
jest.spyOn(Date, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(1030);
const context = {
switchToHttp: () => ({
getRequest: () => ({
method: 'PATCH',
url: '/api/v1/data/bugs',
}),
}),
};
const next = { handle: () => throwError(() => new Error('boom')) };
await expect(lastValueFrom(interceptor.intercept(context as any, next as any))).rejects.toThrow('boom');
expect(warn).toHaveBeenCalledWith(expect.stringContaining('PATCH /api/v1/data/bugs'));
expect(warn).toHaveBeenCalledWith(expect.stringContaining('30ms'));
});
});

View File

@@ -0,0 +1,31 @@
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { finalize, Observable } from 'rxjs';
const DEFAULT_API_SLOW_REQUEST_MS = 1000;
export function resolveApiSlowRequestThreshold(): number {
const parsed = Number(process.env.API_SLOW_REQUEST_MS);
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_API_SLOW_REQUEST_MS;
return Math.floor(parsed);
}
@Injectable()
export class ApiTimingInterceptor implements NestInterceptor {
private readonly logger = new Logger(ApiTimingInterceptor.name);
private readonly thresholdMs = resolveApiSlowRequestThreshold();
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const startedAt = Date.now();
return next.handle().pipe(
finalize(() => {
const durationMs = Date.now() - startedAt;
if (durationMs < this.thresholdMs) return;
const request = context.switchToHttp().getRequest<{ method?: string; originalUrl?: string; url?: string }>();
const method = request.method ?? 'UNKNOWN';
const url = request.originalUrl ?? request.url ?? 'unknown-url';
this.logger.warn(`Slow API request: ${method} ${url} ${durationMs}ms`);
}),
);
}
}

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { MigrationModule } from '../migration/migration.module';
import { DataController } from './data.controller';
import { DataService } from './data.service';
@Module({
imports: [MigrationModule],
controllers: [DataController],
providers: [DataService],
})

View File

@@ -11,9 +11,13 @@ describe('DataService', () => {
upsert: jest.fn(),
},
};
const syncService = {
syncAfterAppDataPut: jest.fn(),
};
return {
prisma,
service: new DataService(prisma as any),
syncService,
service: new DataService(prisma as any, syncService as any),
};
};
@@ -51,7 +55,7 @@ describe('DataService', () => {
});
it('upserts JSON values for allowed keys', async () => {
const { prisma, service } = makeService();
const { prisma, service, syncService } = makeService();
const value = [{ id: 'p1', name: 'Product 1' }];
const updatedAt = new Date('2026-07-02T08:01:00.000Z');
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
@@ -66,6 +70,7 @@ describe('DataService', () => {
update: { value },
create: { key: 'products-overview', value },
});
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
});
it('allows supporting business data keys migrated from browser storage', async () => {
@@ -84,7 +89,7 @@ describe('DataService', () => {
});
it('updates only when the supplied version matches the stored row version', async () => {
const { prisma, service } = makeService();
const { prisma, service, syncService } = makeService();
const previousVersion = '2026-07-02T08:03:00.000Z';
const nextUpdatedAt = new Date('2026-07-02T08:04:00.000Z');
const nextValue = [{ id: 'p2', name: 'Product 2' }];
@@ -107,10 +112,11 @@ describe('DataService', () => {
},
data: { value: nextValue },
});
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
});
it('rejects stale AppData versions without overwriting the current value', async () => {
const { prisma, service } = makeService();
const { prisma, service, syncService } = makeService();
prisma.appData.updateMany.mockResolvedValue({ count: 0 });
prisma.appData.findUnique.mockResolvedValue({
key: 'products-overview',
@@ -122,10 +128,11 @@ describe('DataService', () => {
service.put('products-overview', [{ id: 'stale' }], '2026-07-02T08:03:00.000Z'),
).rejects.toBeInstanceOf(ConflictException);
expect(prisma.appData.upsert).not.toHaveBeenCalled();
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
});
it('creates a missing row only when the client loaded a null version', async () => {
const { prisma, service } = makeService();
const { prisma, service, syncService } = makeService();
const value = [{ id: 'p1' }];
const updatedAt = new Date('2026-07-02T08:06:00.000Z');
prisma.appData.create.mockResolvedValue({ key: 'products-overview', value, updatedAt });
@@ -139,10 +146,11 @@ describe('DataService', () => {
data: { key: 'products-overview', value },
});
expect(prisma.appData.upsert).not.toHaveBeenCalled();
expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview');
});
it('rejects create-only writes when another client created the row first', async () => {
const { prisma, service } = makeService();
const { prisma, service, syncService } = makeService();
prisma.appData.create.mockRejectedValue({ code: 'P2002' });
prisma.appData.findUnique.mockResolvedValue({
key: 'products-overview',
@@ -153,6 +161,7 @@ describe('DataService', () => {
await expect(service.put('products-overview', [{ id: 'new' }], null)).rejects.toBeInstanceOf(
ConflictException,
);
expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled();
});
it('rejects invalid AppData versions', async () => {
@@ -162,4 +171,20 @@ describe('DataService', () => {
BadRequestException,
);
});
it('keeps the AppData response successful when relation sync fails', async () => {
const { prisma, service, syncService } = makeService();
const value = [{ id: 'p1' }];
const updatedAt = new Date('2026-07-02T08:08:00.000Z');
const warnSpy = jest.spyOn((service as any).logger, 'warn').mockImplementation();
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
syncService.syncAfterAppDataPut.mockRejectedValue(new Error('sync failed'));
await expect(service.put('products-overview', value)).resolves.toEqual({
key: 'products-overview',
value,
version: updatedAt.toISOString(),
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('products-overview'));
});
});

View File

@@ -1,6 +1,7 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AppDataV23SyncService } from '../migration/app-data-v23-sync.service';
import { isAppDataKey } from './data-keys';
type AppDataRow = {
@@ -11,7 +12,12 @@ type AppDataRow = {
@Injectable()
export class DataService {
constructor(private prisma: PrismaService) {}
private readonly logger = new Logger(DataService.name);
constructor(
private prisma: PrismaService,
private readonly appDataSync?: AppDataV23SyncService,
) {}
async get(key: string) {
this.ensureAllowedKey(key);
@@ -28,7 +34,7 @@ export class DataService {
const row = await this.prisma.appData.create({
data: { key, value: jsonValue },
});
return this.toResponse(key, row);
return this.toResponseAfterSync(key, row);
} catch (error) {
if (this.isUniqueConflict(error)) {
await this.throwConflict(key);
@@ -49,7 +55,7 @@ export class DataService {
}
const row = await this.prisma.appData.findUnique({ where: { key } });
return this.toResponse(key, row);
return this.toResponseAfterSync(key, row);
}
const row = await this.prisma.appData.upsert({
@@ -57,7 +63,7 @@ export class DataService {
update: { value: jsonValue },
create: { key, value: jsonValue },
});
return this.toResponse(key, row);
return this.toResponseAfterSync(key, row);
}
private ensureAllowedKey(key: string) {
@@ -82,6 +88,20 @@ export class DataService {
};
}
private async toResponseAfterSync(key: string, row: AppDataRow | null) {
await this.syncRelations(key);
return this.toResponse(key, row);
}
private async syncRelations(key: string) {
if (!this.appDataSync) return;
try {
await this.appDataSync.syncAfterAppDataPut(key);
} catch (error: any) {
this.logger.warn(`AppData relation sync failed for ${key}: ${error?.message ?? error}`);
}
}
private async throwConflict(key: string): Promise<never> {
const row = await this.prisma.appData.findUnique({ where: { key } });
const current = this.toResponse(key, row);

View File

@@ -1,7 +1,21 @@
import { APP_DATA_KEYS } from '../data/data-keys';
import { AppDataV22MigrationService } from './app-data-v22.migration.service';
import { AppDataV22MigrationService, normalizeDateTimeFields } from './app-data-v22.migration.service';
describe('AppDataV22MigrationService', () => {
it('normalizes date-only fields used by relation-table writes', () => {
expect(
normalizeDateTimeFields({
workDate: '2026-07-03',
snapshotDate: '2026-07-03',
createdAt: '2026-07-03',
}),
).toEqual({
workDate: '2026-07-03T00:00:00.000Z',
snapshotDate: '2026-07-03T00:00:00.000Z',
createdAt: '2026-07-03T00:00:00.000Z',
});
});
it('loads allowed AppData keys and returns a migration preview without writing domain tables', async () => {
const findMany = jest.fn().mockResolvedValue([
{

View File

@@ -89,6 +89,8 @@ const DATE_TIME_KEYS = new Set([
'aiDraftAt',
'recomputedAt',
'forecastReleaseDate',
'snapshotDate',
'workDate',
]);
export interface AppDataV22MigrationPreview {
@@ -172,7 +174,7 @@ async function createMany(delegate: CreateManyDelegate, data: unknown[]): Promis
return result.count;
}
function normalizeDateTimeFields(row: unknown): unknown {
export function normalizeDateTimeFields(row: unknown): unknown {
if (typeof row !== 'object' || row === null || Array.isArray(row)) return row;
return Object.fromEntries(
Object.entries(row).map(([key, value]) => [

View File

@@ -0,0 +1,186 @@
import { AppDataV23SyncService } from './app-data-v23-sync.service';
describe('AppDataV23SyncService', () => {
const makeDelegate = () => ({
createMany: jest.fn().mockResolvedValue({ count: 0 }),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
upsert: jest.fn().mockResolvedValue({}),
});
const makePrisma = (snapshot: Record<string, unknown>) => {
const prisma: any = {
appData: {
findMany: jest.fn().mockResolvedValue(
Object.entries(snapshot).map(([key, value]) => ({
key,
value,
})),
),
},
user: makeDelegate(),
product: makeDelegate(),
project: makeDelegate(),
version: makeDelegate(),
taskCategory: makeDelegate(),
requirement: makeDelegate(),
versionPlan: makeDelegate(),
devTask: makeDelegate(),
testCase: makeDelegate(),
bug: makeDelegate(),
workActivity: makeDelegate(),
taskWorklog: makeDelegate(),
overtimeRecord: makeDelegate(),
xiaobaoRiskSnapshot: makeDelegate(),
xiaobaoRiskInsight: makeDelegate(),
xiaobaoRiskSummary: makeDelegate(),
$transaction: jest.fn(async (callback: (tx: any) => Promise<unknown>): Promise<unknown> => callback(prisma)),
};
return prisma;
};
const productTree = [
{
id: 'product-1',
name: 'Product 1',
projects: [{ id: 'project-1', name: 'Project 1' }],
versions: [{ id: 'version-1', projectId: 'project-1', name: 'V1' }],
},
];
it('replaces requirement rows by product partition scope', async () => {
const prisma = makePrisma({
'products-overview': productTree,
requirements: [
{
id: 'req-1',
productId: 'product-1',
versionId: 'version-1',
code: 'REQ-001',
title: 'Login',
status: 'adopted',
},
],
});
const service = new AppDataV23SyncService(prisma as any);
await service.syncAfterAppDataPut('requirements');
expect(prisma.requirement.deleteMany).toHaveBeenCalledWith({
where: { productId: { in: ['product-1'] } },
});
expect(prisma.requirement.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
id: 'req-1',
productId: 'product-1',
versionId: 'version-1',
code: 'REQ-001',
}),
],
skipDuplicates: true,
});
});
it('replaces version detail rows by version partition scope', async () => {
const prisma = makePrisma({
'products-overview': productTree,
'version-plans': [
{
id: 'plan-1',
versionId: 'version-1',
type: 'product',
title: 'PRD',
status: 'in_progress',
},
],
'dev-tasks': [
{
id: 'dev-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
code: 'DEV-001',
title: 'API',
status: 'in_progress',
},
],
'test-cases': [
{
id: 'tc-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
code: 'TC-001',
title: 'Login success',
status: 'running',
},
],
bugs: [
{
id: 'bug-1',
versionId: 'version-1',
productId: 'product-1',
projectId: 'project-1',
code: 'BUG-001',
title: 'Login failure',
status: 'open',
},
],
});
const service = new AppDataV23SyncService(prisma as any);
await service.syncAfterAppDataPut('dev-tasks');
expect(prisma.devTask.deleteMany).toHaveBeenCalledWith({
where: { versionId: { in: ['version-1'] } },
});
expect(prisma.devTask.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ id: 'dev-1', versionId: 'version-1', code: 'DEV-001' })],
skipDuplicates: true,
});
expect(prisma.testCase.deleteMany).not.toHaveBeenCalled();
expect(prisma.bug.deleteMany).not.toHaveBeenCalled();
expect(prisma.xiaobaoRiskSummary.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { versionId: 'version-1' },
update: { dirty: true },
}),
);
});
it('refreshes Xiaobao summaries from the latest risk snapshot', async () => {
const prisma = makePrisma({
'products-overview': productTree,
'xiaobao-risk-snapshots': [
{
id: 'snapshot-1',
versionId: 'version-1',
snapshotDate: '2026-07-03',
riskLevel: 'likely_delayed',
riskScore: 78,
confidence: 85,
forecastReleaseDate: '2026-07-10',
createdAt: '2026-07-03T10:00:00.000Z',
},
],
});
const service = new AppDataV23SyncService(prisma as any);
await service.syncAfterAppDataPut('xiaobao-risk-snapshots');
expect(prisma.xiaobaoRiskSnapshot.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ id: 'snapshot-1', versionId: 'version-1', riskScore: 78 })],
skipDuplicates: true,
});
expect(prisma.xiaobaoRiskSummary.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { versionId: 'version-1' },
update: expect.objectContaining({
riskLevel: 'likely_delayed',
riskScore: 78,
dirty: false,
}),
}),
);
});
});

View File

@@ -0,0 +1,270 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { APP_DATA_KEYS, isAppDataKey } from '../data/data-keys';
import { mapAppDataToV22Rows, type V22MappedRows } from './app-data-v22.mapper';
import { normalizeDateTimeFields } from './app-data-v22.migration.service';
type DelegateName =
| 'user'
| 'product'
| 'project'
| 'version'
| 'taskCategory'
| 'requirement'
| 'versionPlan'
| 'devTask'
| 'testCase'
| 'bug'
| 'workActivity'
| 'taskWorklog'
| 'overtimeRecord'
| 'xiaobaoRiskSnapshot'
| 'xiaobaoRiskInsight'
| 'xiaobaoRiskSummary';
type CreateManyDelegate = {
createMany(args: { data: unknown[]; skipDuplicates: boolean }): Promise<{ count: number }>;
};
type DeleteManyDelegate = {
deleteMany(args: { where: unknown }): Promise<{ count: number }>;
};
type UpsertDelegate = {
upsert(args: unknown): Promise<unknown>;
};
type SyncDelegate = Partial<CreateManyDelegate & DeleteManyDelegate & UpsertDelegate>;
type SyncTransaction = Record<DelegateName, SyncDelegate>;
const RISK_INPUT_KEYS = new Set([
'version-plans',
'dev-tasks',
'test-cases',
'bugs',
'work-activities',
'task-worklogs',
'overtime',
]);
@Injectable()
export class AppDataV23SyncService {
constructor(private readonly prisma: PrismaService) {}
async syncAfterAppDataPut(key: string): Promise<void> {
if (!isAppDataKey(key)) return;
const mapped = mapAppDataToV22Rows(await this.loadSnapshot());
await this.prisma.$transaction(async (tx) => {
const client = tx as unknown as SyncTransaction;
await this.syncBaseTables(client, key, mapped);
await this.syncCurrentStateTables(client, key, mapped);
await this.syncAppendOnlyTables(client, key, mapped);
await this.syncXiaobaoSummaries(client, key, mapped);
});
}
private async loadSnapshot(): Promise<Record<string, unknown>> {
const rows = await this.prisma.appData.findMany({
where: { key: { in: [...APP_DATA_KEYS] } },
select: { key: true, value: true },
});
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
}
private async syncBaseTables(client: SyncTransaction, key: string, mapped: V22MappedRows) {
if (key === 'products-overview') {
await upsertById(client.product, mapped.products);
await upsertById(client.project, mapped.projects);
await upsertById(client.version, mapped.versions);
}
if (key === 'members') {
await upsertById(client.user, mapped.users);
}
if (key === 'task-categories') {
await upsertById(client.taskCategory, mapped.taskCategories);
}
}
private async syncCurrentStateTables(client: SyncTransaction, key: string, mapped: V22MappedRows) {
if (key === 'requirements') {
const productIds = getProductScope(mapped);
await replaceRows(
client.requirement,
{ productId: { in: productIds } },
mapped.requirements.filter((row) => productIds.includes(row.productId)),
);
}
if (key === 'version-plans') {
const versionIds = getVersionScope(mapped, mapped.versionPlans.map((row) => row.versionId));
await replaceRows(
client.versionPlan,
{ versionId: { in: versionIds } },
mapped.versionPlans.filter((row) => versionIds.includes(row.versionId)),
);
}
if (key === 'dev-tasks') {
const versionIds = getVersionScope(mapped, mapped.devTasks.map((row) => row.versionId));
await replaceRows(
client.devTask,
{ versionId: { in: versionIds } },
mapped.devTasks.filter((row) => versionIds.includes(row.versionId)),
);
}
if (key === 'test-cases') {
const versionIds = getVersionScope(mapped, mapped.testCases.map((row) => row.versionId));
await replaceRows(
client.testCase,
{ versionId: { in: versionIds } },
mapped.testCases.filter((row) => versionIds.includes(row.versionId)),
);
}
if (key === 'bugs') {
const versionIds = getVersionScope(mapped, mapped.bugs.map((row) => row.versionId));
await replaceRows(
client.bug,
{ versionId: { in: versionIds } },
mapped.bugs.filter((row) => versionIds.includes(row.versionId)),
);
}
}
private async syncAppendOnlyTables(client: SyncTransaction, key: string, mapped: V22MappedRows) {
if (key === 'work-activities') {
await createMany(client.workActivity, mapped.workActivities);
}
if (key === 'task-worklogs') {
await createMany(client.taskWorklog, mapped.taskWorklogs);
}
if (key === 'overtime') {
await createMany(client.overtimeRecord, mapped.overtimeRecords);
}
if (key === 'xiaobao-risk-snapshots') {
await createMany(client.xiaobaoRiskSnapshot, mapped.xiaobaoRiskSnapshots);
}
if (key === 'xiaobao-risk-insights') {
await createMany(client.xiaobaoRiskInsight, mapped.xiaobaoRiskInsights);
}
}
private async syncXiaobaoSummaries(client: SyncTransaction, key: string, mapped: V22MappedRows) {
if (key === 'xiaobao-risk-snapshots') {
await upsertRiskSummaries(client.xiaobaoRiskSummary, mapped.xiaobaoRiskSummaries);
return;
}
if (!RISK_INPUT_KEYS.has(key)) return;
await markSummariesDirty(client.xiaobaoRiskSummary, getAffectedRiskVersionIds(key, mapped));
}
}
async function upsertById(delegate: SyncDelegate, rows: Array<{ id: string }>) {
if (!delegate.upsert) return;
for (const row of rows) {
const data = normalizeDateTimeFields(row);
await delegate.upsert({
where: { id: row.id },
update: omitKeys(data, ['id']),
create: data,
});
}
}
async function replaceRows(delegate: SyncDelegate, where: unknown, rows: unknown[]) {
if (!delegate.deleteMany || !delegate.createMany) return;
await delegate.deleteMany({ where });
await createMany(delegate, rows);
}
async function createMany(delegate: SyncDelegate, rows: unknown[]) {
if (!delegate.createMany || rows.length === 0) return;
await delegate.createMany({
data: rows.map(normalizeDateTimeFields),
skipDuplicates: true,
});
}
async function upsertRiskSummaries(delegate: SyncDelegate, rows: V22MappedRows['xiaobaoRiskSummaries']) {
if (!delegate.upsert) return;
for (const row of rows) {
const data = normalizeDateTimeFields(row);
await delegate.upsert({
where: { versionId: row.versionId },
update: omitKeys(data, ['versionId', 'updatedAt']),
create: data,
});
}
}
async function markSummariesDirty(delegate: SyncDelegate, versionIds: string[]) {
if (!delegate.upsert) return;
for (const versionId of unique(versionIds)) {
const riskSignature = `dirty:${versionId}`;
await delegate.upsert({
where: { versionId },
update: { dirty: true },
create: {
versionId,
riskLevel: 'attention',
riskScore: 1,
confidence: 0,
riskSignature,
summary: {
versionId,
riskLevel: 'attention',
riskScore: 1,
confidence: 0,
riskSignature,
dirty: true,
},
dirty: true,
},
});
}
}
function getProductScope(mapped: V22MappedRows): string[] {
return unique([
...mapped.products.map((row) => row.id),
...mapped.requirements.map((row) => row.productId),
]);
}
function getVersionScope(mapped: V22MappedRows, fallback: string[]): string[] {
return unique([...mapped.versions.map((row) => row.id), ...fallback]);
}
function getAffectedRiskVersionIds(key: string, mapped: V22MappedRows): string[] {
if (key === 'version-plans') return mapped.versionPlans.map((row) => row.versionId);
if (key === 'dev-tasks') return mapped.devTasks.map((row) => row.versionId);
if (key === 'test-cases') return mapped.testCases.map((row) => row.versionId);
if (key === 'bugs') return mapped.bugs.map((row) => row.versionId);
if (key === 'work-activities') return mapped.workActivities.map((row) => row.versionId).filter(isPresent);
if (key === 'task-worklogs') return mapped.taskWorklogs.map((row) => row.versionId).filter(isPresent);
if (key === 'overtime') return mapped.overtimeRecords.map((row) => row.versionId).filter(isPresent);
return [];
}
function omitKeys(value: unknown, keys: string[]): unknown {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return value;
const blocked = new Set(keys);
return Object.fromEntries(Object.entries(value).filter(([key]) => !blocked.has(key)));
}
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(isPresent)));
}
function isPresent(value: string | undefined): value is string {
return Boolean(value);
}

View File

@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { AppDataV22MigrationService } from './app-data-v22.migration.service';
import { AppDataV23SyncService } from './app-data-v23-sync.service';
@Module({
providers: [AppDataV22MigrationService],
exports: [AppDataV22MigrationService],
providers: [AppDataV22MigrationService, AppDataV23SyncService],
exports: [AppDataV22MigrationService, AppDataV23SyncService],
})
export class MigrationModule {}

View File

@@ -0,0 +1,29 @@
import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring';
describe('prisma monitoring helpers', () => {
const originalEnv = process.env.PRISMA_SLOW_QUERY_MS;
afterEach(() => {
process.env.PRISMA_SLOW_QUERY_MS = originalEnv;
});
it('uses a safe default threshold when env is missing or invalid', () => {
delete process.env.PRISMA_SLOW_QUERY_MS;
expect(resolvePrismaSlowQueryThreshold()).toBe(300);
process.env.PRISMA_SLOW_QUERY_MS = '-1';
expect(resolvePrismaSlowQueryThreshold()).toBe(300);
});
it('uses a positive configured threshold', () => {
process.env.PRISMA_SLOW_QUERY_MS = '750';
expect(resolvePrismaSlowQueryThreshold()).toBe(750);
});
it('detects slow query events at or above the threshold', () => {
expect(shouldLogPrismaQuery(299, 300)).toBe(false);
expect(shouldLogPrismaQuery(300, 300)).toBe(true);
expect(shouldLogPrismaQuery(450, 300)).toBe(true);
});
});

View File

@@ -0,0 +1,11 @@
const DEFAULT_PRISMA_SLOW_QUERY_MS = 300;
export function resolvePrismaSlowQueryThreshold(): number {
const parsed = Number(process.env.PRISMA_SLOW_QUERY_MS);
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_PRISMA_SLOW_QUERY_MS;
return Math.floor(parsed);
}
export function shouldLogPrismaQuery(durationMs: number, thresholdMs: number): boolean {
return durationMs >= thresholdMs;
}

View File

@@ -1,11 +1,24 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { resolvePrismaSlowQueryThreshold, shouldLogPrismaQuery } from './prisma-monitoring';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
private readonly slowQueryThresholdMs = resolvePrismaSlowQueryThreshold();
private connected = false;
constructor() {
super({
log: [{ emit: 'event', level: 'query' }],
});
(this as any).$on('query', (event: { duration: number; query: string }) => {
if (!shouldLogPrismaQuery(event.duration, this.slowQueryThresholdMs)) return;
this.logger.warn(`Slow Prisma query: ${event.duration}ms ${event.query}`);
});
}
async onModuleInit() {
try {
await this.$connect();