feat: v2 core logger

This commit is contained in:
nichenqin 2025-12-20 10:25:59 +08:00
parent 409c82f6da
commit e4e5e2dfd0
26 changed files with 383 additions and 53 deletions

View File

@ -4,17 +4,23 @@ import { ConfigService } from '@nestjs/config';
import { createV2NodePgContainer } from '@teable/v2-container-node';
import { v2PostgresDbTokens } from '@teable/v2-db-postgres';
import type { DependencyContainer } from '@teable/v2-di';
import { PinoLogger } from 'nestjs-pino';
import { PinoLoggerAdapter } from './v2-logger.adapter';
@Injectable()
export class V2ContainerService implements OnModuleDestroy {
private containerPromise?: Promise<DependencyContainer>;
constructor(private readonly configService: ConfigService) {}
constructor(
private readonly configService: ConfigService,
private readonly pinoLogger: PinoLogger
) {}
async getContainer(): Promise<DependencyContainer> {
if (!this.containerPromise) {
const connectionString = this.configService.getOrThrow<string>('PRISMA_DATABASE_URL');
this.containerPromise = createV2NodePgContainer({ connectionString });
const logger = new PinoLoggerAdapter(this.pinoLogger);
this.containerPromise = createV2NodePgContainer({ connectionString, logger });
}
return this.containerPromise;

View File

@ -0,0 +1,38 @@
import type { ILogger, LogContext } from '@teable/v2-core';
import type { PinoLogger } from 'nestjs-pino';
export class PinoLoggerAdapter implements ILogger {
constructor(private readonly logger: PinoLogger) {}
debug(message: string, context?: LogContext): void {
if (context) {
this.logger.debug(context, message);
return;
}
this.logger.debug(message);
}
info(message: string, context?: LogContext): void {
if (context) {
this.logger.info(context, message);
return;
}
this.logger.info(message);
}
warn(message: string, context?: LogContext): void {
if (context) {
this.logger.warn(context, message);
return;
}
this.logger.warn(message);
}
error(message: string, context?: LogContext): void {
if (context) {
this.logger.error(context, message);
return;
}
this.logger.error(message);
}
}

View File

@ -0,0 +1,29 @@
/**
* Specific eslint rules for this workspace, learn how to compose
* @link https://github.com/teableio/teable/tree/main/packages/eslint-config-bases
*/
require('@teable/eslint-config-bases/patch/modern-module-resolution');
const { getDefaultIgnorePatterns } = require('@teable/eslint-config-bases/helpers');
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.eslint.json',
},
ignorePatterns: [...getDefaultIgnorePatterns()],
extends: [
'@teable/eslint-config-bases/typescript',
'@teable/eslint-config-bases/sonar',
'@teable/eslint-config-bases/regexp',
'@teable/eslint-config-bases/jest',
// Apply prettier and disable incompatible rules
'@teable/eslint-config-bases/prettier-plugin',
],
rules: {
'@typescript-eslint/consistent-type-imports': 'off',
},
overrides: [],
};

View File

@ -0,0 +1,12 @@
# build
/dist
# dependencies
node_modules
# testing
/coverage
# misc
.DS_Store
*.pem

View File

@ -0,0 +1,39 @@
{
"name": "@teable/v2-adapter-logger-console",
"version": "0.0.0",
"private": true,
"license": "MIT",
"sideEffects": false,
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsdown --tsconfig tsconfig.build.json",
"dev": "tsdown --tsconfig tsconfig.build.json --watch",
"clean": "rimraf ./dist ./coverage ./tsconfig.tsbuildinfo ./tsconfig.build.tsbuildinfo ./.eslintcache",
"lint": "eslint . --ext .ts,.js,.mjs,.cjs,.mts,.cts --cache --cache-location ../../../.cache/eslint/v2-adapter-logger-console.eslintcache",
"typecheck": "tsc --project ./tsconfig.json --noEmit",
"test-unit": "vitest run --silent",
"test-unit-cover": "pnpm test-unit --coverage",
"fix-all-files": "eslint . --ext .ts,.js,.mjs,.cjs,.mts,.cts --fix"
},
"dependencies": {
"@teable/v2-core": "workspace:*"
},
"devDependencies": {
"@teable/eslint-config-bases": "workspace:^",
"@teable/v2-tsdown-config": "workspace:*",
"@types/node": "22.18.0",
"@vitest/coverage-v8": "4.0.16",
"eslint": "8.57.0",
"prettier": "3.2.5",
"rimraf": "5.0.5",
"tsdown": "0.18.1",
"typescript": "5.4.3",
"vite-tsconfig-paths": "4.3.2",
"vitest": "4.0.16"
}
}

View File

@ -0,0 +1,30 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type { ILogger, LogContext } from '@teable/v2-core';
type ConsoleLogFn = (...args: unknown[]) => void;
export class ConsoleLogger implements ILogger {
private log(logFn: ConsoleLogFn, message: string, context?: LogContext): void {
if (context) {
logFn.call(console, message, context);
return;
}
logFn.call(console, message);
}
debug(message: string, context?: LogContext): void {
this.log(console.debug, message, context);
}
info(message: string, context?: LogContext): void {
this.log(console.info, message, context);
}
warn(message: string, context?: LogContext): void {
this.log(console.warn, message, context);
}
error(message: string, context?: LogContext): void {
this.log(console.error, message, context);
}
}

View File

@ -0,0 +1 @@
export * from './ConsoleLogger';

View File

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"paths": {}
},
"exclude": ["dist", "**/__tests__/**", "**/*.spec.ts", "**/*.test.ts"],
"include": ["src"]
}

View File

@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"allowJs": true
},
"exclude": ["node_modules", "**/.*/*", "dist"],
"include": [
".eslintrc.*",
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.js",
"**/*.cjs",
"**/*.mjs",
"**/*.jsx",
"**/*.json"
]
}

View File

@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "@teable/v2-adapter-logger-console",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "esnext",
"lib": ["esnext", "dom"],
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"noEmit": false,
"incremental": true,
"resolveJsonModule": true,
"declaration": true,
"declarationDir": "dist",
"composite": true,
"rootDir": "../",
"outDir": "dist",
"paths": {
"@teable/v2-core": ["../core/src"]
},
"types": ["vitest/globals", "node"]
},
"exclude": ["**/node_modules", "**/.*/", "./dist", "./coverage"],
"include": ["src", "../core/src"]
}

View File

@ -0,0 +1,5 @@
import { defineConfig } from 'tsdown';
import { v2TsdownBaseConfig } from '@teable/v2-tsdown-config';
export default defineConfig(v2TsdownBaseConfig);

View File

@ -0,0 +1,29 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig, configDefaults } from 'vitest/config';
const testFiles = ['./src/**/*.{test,spec}.{js,ts}'];
export default defineConfig({
plugins: [tsconfigPaths()],
cacheDir: '../../../.cache/vitest/v2-adapter-logger-console',
test: {
globals: true,
environment: 'node',
passWithNoTests: true,
typecheck: {
enabled: false,
},
pool: 'forks',
fileParallelism: false,
coverage: {
provider: 'v8',
extension: ['.js', '.ts'],
include: ['src/**/*'],
},
clearMocks: true,
mockReset: true,
restoreMocks: true,
include: testFiles,
exclude: [...configDefaults.exclude, '**/.next/**'],
},
});

View File

@ -1,6 +1,5 @@
import { z } from 'zod';
import { v2PostgresDbConfigSchema } from '@teable/v2-db-postgres';
import { z } from 'zod';
export const v2PostgresStateAdapterConfigSchema = v2PostgresDbConfigSchema.extend({
ensureSchema: z.boolean().optional(),

View File

@ -1,5 +1,6 @@
import {
NoopEventPublisher,
NoopLogger,
NoopTableRepository,
NoopTableSchemaRepository,
NoopUnitOfWork,
@ -23,6 +24,9 @@ export const registerV2BrowserNoopDependencies = (
c.register(v2CoreTokens.unitOfWork, NoopUnitOfWork, {
lifecycle: Lifecycle.Singleton,
});
c.register(v2CoreTokens.logger, NoopLogger, {
lifecycle: Lifecycle.Singleton,
});
return c;
};

View File

@ -21,6 +21,7 @@
"fix-all-files": "eslint . --ext .ts,.js,.mjs,.cjs,.mts,.cts --fix"
},
"dependencies": {
"@teable/v2-adapter-logger-console": "workspace:*",
"@teable/v2-adapter-postgres-ddl": "workspace:*",
"@teable/v2-adapter-postgres-state": "workspace:*",
"@teable/v2-core": "workspace:*",

View File

@ -1,3 +1,4 @@
import { ConsoleLogger } from '@teable/v2-adapter-logger-console';
import { registerV2PostgresDdlAdapter } from '@teable/v2-adapter-postgres-ddl';
import { registerV2PostgresStateAdapter } from '@teable/v2-adapter-postgres-state';
import type { ITableRepository } from '@teable/v2-core';
@ -37,6 +38,7 @@ export const createV2NodeTestContainer = async (): Promise<IV2NodeTestContainer>
c.register(v2CoreTokens.unitOfWork, PostgresUnitOfWork, {
lifecycle: Lifecycle.Singleton,
});
c.registerInstance(v2CoreTokens.logger, new ConsoleLogger());
const tableRepository = c.resolve<ITableRepository>(v2CoreTokens.tableRepository);
const eventPublisher = new MemoryEventPublisher();

View File

@ -20,6 +20,7 @@
"rootDir": "../",
"outDir": "dist",
"paths": {
"@teable/v2-adapter-logger-console": ["../adapter-logger-console/src"],
"@teable/v2-adapter-postgres-ddl": ["../adapter-postgres-ddl/src"],
"@teable/v2-adapter-postgres-state": ["../adapter-postgres-state/src"],
"@teable/v2-core": ["../core/src"],
@ -31,6 +32,7 @@
"exclude": ["**/node_modules", "**/.*/", "./dist", "./coverage"],
"include": [
"src",
"../adapter-logger-console/src",
"../adapter-postgres-ddl/src",
"../adapter-postgres-state/src",
"../core/src",

View File

@ -1,7 +1,7 @@
import { registerV2PostgresDdlAdapter } from '@teable/v2-adapter-postgres-ddl';
import type { IV2PostgresStateAdapterConfig } from '@teable/v2-adapter-postgres-state';
import { registerV2PostgresStateAdapter } from '@teable/v2-adapter-postgres-state';
import { NoopEventPublisher, v2CoreTokens } from '@teable/v2-core';
import { NoopEventPublisher, NoopLogger, v2CoreTokens, type ILogger } from '@teable/v2-core';
import { PostgresUnitOfWork } from '@teable/v2-db-postgres';
import type { DependencyContainer } from '@teable/v2-di';
import { Lifecycle, container } from '@teable/v2-di';
@ -10,6 +10,7 @@ export interface IV2NodePgContainerOptions {
connectionString?: string;
ensureSchema?: boolean;
seed?: Partial<IV2PostgresStateAdapterConfig['seed']>;
logger?: ILogger;
}
export const registerV2NodePgDependencies = async (
@ -42,6 +43,14 @@ export const registerV2NodePgDependencies = async (
lifecycle: Lifecycle.Singleton,
});
if (options.logger) {
c.registerInstance(v2CoreTokens.logger, options.logger);
} else {
c.register(v2CoreTokens.logger, NoopLogger, {
lifecycle: Lifecycle.Singleton,
});
}
return c;
};

View File

@ -24,6 +24,7 @@ module.exports = {
],
rules: {
'@typescript-eslint/naming-convention': 'off',
'no-console': 'error',
},
overrides: [
{

View File

@ -5,12 +5,13 @@ import type { Result } from 'neverthrow';
import type { IDomainEvent } from '../domain/shared/DomainEvent';
import type { Table } from '../domain/table/Table';
import { Table as TableAggregate } from '../domain/table/Table';
import { IEventPublisher } from '../ports/EventPublisher';
import type { IExecutionContext } from '../ports/ExecutionContext';
import type { IEventPublisher } from '../ports/EventPublisher';
import type { ITableRepository } from '../ports/TableRepository';
import type { ITableSchemaRepository } from '../ports/TableSchemaRepository';
import { ILogger } from '../ports/Logger';
import { ITableRepository } from '../ports/TableRepository';
import { ITableSchemaRepository } from '../ports/TableSchemaRepository';
import { v2CoreTokens } from '../ports/tokens';
import type { IUnitOfWork } from '../ports/UnitOfWork';
import { IUnitOfWork } from '../ports/UnitOfWork';
import type { CreateTableCommand } from './CreateTableCommand';
export class CreateTableResult {
@ -33,6 +34,8 @@ export class CreateTableHandler {
private readonly tableSchemaRepository: ITableSchemaRepository,
@inject(v2CoreTokens.eventPublisher)
private readonly eventPublisher: IEventPublisher,
@inject(v2CoreTokens.logger)
private readonly logger: ILogger,
@inject(v2CoreTokens.unitOfWork)
private readonly unitOfWork: IUnitOfWork
) {}
@ -41,6 +44,14 @@ export class CreateTableHandler {
context: IExecutionContext,
command: CreateTableCommand
): Promise<Result<CreateTableResult, string>> {
this.logger.info('CreateTableHandler.start', {
actorId: context.actorId.toString(),
baseId: command.baseId.toString(),
tableName: command.tableName.toString(),
fieldCount: command.fields.length,
viewCount: command.views.length,
});
const tableResult = this.buildTable(command);
if (tableResult.isErr()) return err(tableResult.error);
const table = tableResult.value;
@ -64,6 +75,12 @@ export class CreateTableHandler {
const publishResult = this.eventPublisher.publishMany(context, events);
if (publishResult.isErr()) return err(publishResult.error);
this.logger.info('CreateTableHandler.success', {
baseId: command.baseId.toString(),
tableId: table.id().toString(),
eventCount: events.length,
});
return ok(CreateTableResult.create(table, events));
}

View File

@ -76,6 +76,7 @@ export type { PluginView } from './domain/table/views/types/PluginView';
export * from './ports/EventPublisher';
export * from './ports/ExecutionContext';
export * from './ports/Logger';
export * from './ports/TableRepository';
export * from './ports/TableSchemaRepository';
export * from './ports/UnitOfWork';

View File

@ -0,0 +1,8 @@
export type LogContext = Readonly<Record<string, unknown>>;
export interface ILogger {
debug(message: string, context?: LogContext): void;
info(message: string, context?: LogContext): void;
warn(message: string, context?: LogContext): void;
error(message: string, context?: LogContext): void;
}

View File

@ -0,0 +1,12 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import type { ILogger, LogContext } from '../Logger';
export class NoopLogger implements ILogger {
debug(_: string, __?: LogContext): void {}
info(_: string, __?: LogContext): void {}
warn(_: string, __?: LogContext): void {}
error(_: string, __?: LogContext): void {}
}

View File

@ -1,4 +1,5 @@
export * from './NoopEventPublisher';
export * from './NoopLogger';
export * from './NoopTableRepository';
export * from './NoopTableSchemaRepository';
export * from './NoopUnitOfWork';

View File

@ -3,4 +3,5 @@ export const v2CoreTokens = {
tableSchemaRepository: Symbol('v2.core.tableSchemaRepository'),
eventPublisher: Symbol('v2.core.eventPublisher'),
unitOfWork: Symbol('v2.core.unitOfWork'),
logger: Symbol('v2.core.logger'),
} as const;

View File

@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable @typescript-eslint/no-empty-function */
import {
ActorId,
CreateTableCommand,
@ -9,7 +11,7 @@ import {
v2CoreTokens,
} from '@teable/v2-core';
import { err } from 'neverthrow';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { getV2NodeTestContainer } from '../testkit/v2NodeTestContainer';
@ -18,57 +20,81 @@ describe('CreateTableHandler', () => {
const { container, tableRepository, eventPublisher, baseId } = getV2NodeTestContainer();
const handler = container.resolve(CreateTableHandler);
const commandResult = CreateTableCommand.create({
baseId: baseId.toString(),
name: 'Projects',
fields: [
{ type: 'singleLineText', name: 'Name', options: { defaultValue: 'Project' } },
{
type: 'rating',
name: 'Priority',
options: { max: 5, icon: 'star', color: 'yellowBright' },
},
{
type: 'singleSelect',
name: 'Status',
options: {
choices: [
{ name: 'Todo', color: 'blue' },
{ name: 'Doing', color: 'yellow' },
{ name: 'Done', color: 'green' },
],
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
try {
const commandResult = CreateTableCommand.create({
baseId: baseId.toString(),
name: 'Projects',
fields: [
{ type: 'singleLineText', name: 'Name', options: { defaultValue: 'Project' } },
{
type: 'rating',
name: 'Priority',
options: { max: 5, icon: 'star', color: 'yellowBright' },
},
},
],
});
{
type: 'singleSelect',
name: 'Status',
options: {
choices: [
{ name: 'Todo', color: 'blue' },
{ name: 'Doing', color: 'yellow' },
{ name: 'Done', color: 'green' },
],
},
},
],
});
expect(commandResult.isOk()).toBe(true);
if (commandResult.isErr()) return;
expect(commandResult.isOk()).toBe(true);
if (commandResult.isErr()) return;
const actorIdResult = ActorId.create('system');
expect(actorIdResult.isOk()).toBe(true);
if (actorIdResult.isErr()) return;
const actorIdResult = ActorId.create('system');
expect(actorIdResult.isOk()).toBe(true);
if (actorIdResult.isErr()) return;
const context = { actorId: actorIdResult.value };
const result = await handler.handle(context, commandResult.value);
expect(result.isOk()).toBe(true);
if (result.isErr()) return;
const context = { actorId: actorIdResult.value };
const result = await handler.handle(context, commandResult.value);
expect(result.isOk()).toBe(true);
if (result.isErr()) return;
expect(eventPublisher.events().some((e) => e instanceof TableCreated)).toBe(true);
expect(result.value.table.primaryFieldId().equals(result.value.table.fields()[0].id())).toBe(
true
);
expect(result.value.table.baseId().equals(baseId)).toBe(true);
expect(infoSpy).toHaveBeenCalledWith(
'CreateTableHandler.start',
expect.objectContaining({
actorId: 'system',
baseId: baseId.toString(),
tableName: 'Projects',
fieldCount: 3,
viewCount: 1,
})
);
expect(infoSpy).toHaveBeenCalledWith(
'CreateTableHandler.success',
expect.objectContaining({
baseId: baseId.toString(),
tableId: result.value.table.id().toString(),
eventCount: result.value.events.length,
})
);
const specResult = Table.specs(baseId).byId(result.value.table.id()).build();
expect(specResult.isOk()).toBe(true);
if (specResult.isErr()) return;
const savedResult = await tableRepository.findOne(context, specResult.value);
expect(savedResult.isOk()).toBe(true);
if (savedResult.isOk()) {
expect(savedResult.value.primaryFieldId().equals(result.value.table.primaryFieldId())).toBe(
expect(eventPublisher.events().some((e) => e instanceof TableCreated)).toBe(true);
expect(result.value.table.primaryFieldId().equals(result.value.table.fields()[0].id())).toBe(
true
);
expect(result.value.table.baseId().equals(baseId)).toBe(true);
const specResult = Table.specs(baseId).byId(result.value.table.id()).build();
expect(specResult.isOk()).toBe(true);
if (specResult.isErr()) return;
const savedResult = await tableRepository.findOne(context, specResult.value);
expect(savedResult.isOk()).toBe(true);
if (savedResult.isOk()) {
expect(savedResult.value.primaryFieldId().equals(result.value.table.primaryFieldId())).toBe(
true
);
}
} finally {
infoSpy.mockRestore();
}
});