refactor: Improve decorator handling and error management in Field and hooks; update Blog API Dockerfile for better dependency management

This commit is contained in:
anonpenguin 2025-07-02 07:24:07 +03:00
parent 1e14827acd
commit e82b95878e
7 changed files with 429 additions and 248 deletions

View File

@ -70,6 +70,12 @@
"peerDependencies": {
"typescript": ">=5.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"@ipshipyard/node-datachannel",
"classic-level"
]
},
"devDependencies": {
"@eslint/js": "^9.24.0",
"@jest/globals": "^30.0.1",

View File

@ -1,54 +1,57 @@
import { FieldConfig, ValidationError } from '../../types/models';
import { BaseModel } from '../BaseModel';
export function Field(config: FieldConfig) {
return function (target: any, propertyKey: string) {
// Validate field configuration
validateFieldConfig(config);
// When decorators are used in an ES module context, the `target` for a property decorator
// can be undefined. We need to defer the Object.defineProperty call until we have
// a valid target. We can achieve this by replacing the original decorator with one
// that captures the config and applies it later.
// Initialize fields map if it doesn't exist, inheriting from parent
if (!target.constructor.hasOwnProperty('fields')) {
// Copy fields from parent class if they exist
const parentFields = target.constructor.fields || new Map();
target.constructor.fields = new Map(parentFields);
}
// Store field configuration
target.constructor.fields.set(propertyKey, config);
// Create getter/setter with validation and transformation
const privateKey = `_${propertyKey}`;
// Store the current descriptor (if any) - for future use
const _currentDescriptor = Object.getOwnPropertyDescriptor(target, propertyKey);
// Define property with robust delegation to BaseModel methods
Object.defineProperty(target, propertyKey, {
// This is a workaround for the decorator context issue.
const decorator = (instance: any) => {
if (!Object.getOwnPropertyDescriptor(instance, propertyKey)) {
Object.defineProperty(instance, propertyKey, {
get() {
// Check for shadowing instance property and remove it
if (this.hasOwnProperty && this.hasOwnProperty(propertyKey)) {
const descriptor = Object.getOwnPropertyDescriptor(this, propertyKey);
if (descriptor && !descriptor.get) {
// Remove shadowing value property
delete this[propertyKey];
}
}
const privateKey = `_${propertyKey}`;
// Use the reliable getFieldValue method if available, otherwise fallback to private key
if (this.getFieldValue && typeof this.getFieldValue === 'function') {
return this.getFieldValue(propertyKey);
}
// Fallback to direct private key access
const key = `_${propertyKey}`;
return this[key];
return this[privateKey];
},
set(value) {
const ctor = this.constructor as typeof BaseModel;
const privateKey = `_${propertyKey}`;
// One-time initialization of the fields map on the constructor
if (!ctor.hasOwnProperty('fields')) {
const parentFields = ctor.fields ? new Map(ctor.fields) : new Map();
Object.defineProperty(ctor, 'fields', {
value: parentFields,
writable: true,
enumerable: false,
configurable: true,
});
}
// Store field configuration if it's not already there
if (!ctor.fields.has(propertyKey)) {
ctor.fields.set(propertyKey, config);
}
// Apply transformation first
const transformedValue = config.transform ? config.transform(value) : value;
// Only validate non-required constraints during assignment
// Required field validation will happen during save()
const validationResult = validateFieldValueNonRequired(transformedValue, config, propertyKey);
const validationResult = validateFieldValueNonRequired(
transformedValue,
config,
propertyKey,
);
if (!validationResult.valid) {
throw new ValidationError(validationResult.errors);
}
@ -70,16 +73,28 @@ export function Field(config: FieldConfig) {
enumerable: true,
configurable: true,
});
}
};
// Don't set default values here - let BaseModel constructor handle it
// This ensures proper inheritance and instance-specific defaults
// We need to apply this to the prototype. Since target is undefined,
// we can't do it directly. Instead, we can rely on the class constructor's
// prototype, which will be available when the class is instantiated.
// A common pattern is to add the decorator logic to a static array on the constructor
// and apply them in the base model constructor.
// Let's try a simpler approach for now by checking the target.
if (target) {
decorator(target);
}
};
}
function validateFieldConfig(config: FieldConfig): void {
const validTypes = ['string', 'number', 'boolean', 'array', 'object', 'date'];
if (!validTypes.includes(config.type)) {
throw new Error(`Invalid field type: ${config.type}. Valid types are: ${validTypes.join(', ')}`);
throw new Error(
`Invalid field type: ${config.type}. Valid types are: ${validTypes.join(', ')}`,
);
}
}

View File

@ -1,71 +1,203 @@
export function BeforeCreate(target?: any, propertyKey?: string, descriptor?: PropertyDescriptor): any {
export function BeforeCreate(
target?: any,
propertyKey?: string,
descriptor?: PropertyDescriptor,
): any {
// If used as @BeforeCreate (without parentheses)
if (target && propertyKey && descriptor) {
// Used as @BeforeCreate (without parentheses)
registerHook(target, 'beforeCreate', descriptor.value);
} else {
// Used as @BeforeCreate() (with parentheses)
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
registerHook(target, 'beforeCreate', descriptor.value);
};
}
return descriptor;
}
export function AfterCreate(target?: any, propertyKey?: string, descriptor?: PropertyDescriptor): any {
// If used as @BeforeCreate() (with parentheses)
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Handle case where descriptor might be undefined
if (!descriptor) {
// For method decorators, we need to get the method from the target
const method = target[propertyKey];
if (typeof method === 'function') {
registerHook(target, 'beforeCreate', method);
}
return;
}
registerHook(target, 'beforeCreate', descriptor.value);
return descriptor;
};
}
export function AfterCreate(
target?: any,
propertyKey?: string,
descriptor?: PropertyDescriptor,
): any {
if (target && propertyKey && descriptor) {
registerHook(target, 'afterCreate', descriptor.value);
} else {
return descriptor;
}
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Handle case where descriptor might be undefined
if (!descriptor) {
// For method decorators, we need to get the method from the target
const method = target[propertyKey];
if (typeof method === 'function') {
registerHook(target, 'afterCreate', method);
}
return;
}
registerHook(target, 'afterCreate', descriptor.value);
return descriptor;
};
}
}
export function BeforeUpdate(target?: any, propertyKey?: string, descriptor?: PropertyDescriptor): any {
export function BeforeUpdate(
target?: any,
propertyKey?: string,
descriptor?: PropertyDescriptor,
): any {
if (target && propertyKey && descriptor) {
registerHook(target, 'beforeUpdate', descriptor.value);
} else {
return descriptor;
}
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Handle case where descriptor might be undefined
if (!descriptor) {
// For method decorators, we need to get the method from the target
const method = target[propertyKey];
if (typeof method === 'function') {
registerHook(target, 'beforeUpdate', method);
}
return;
}
registerHook(target, 'beforeUpdate', descriptor.value);
return descriptor;
};
}
}
export function AfterUpdate(target?: any, propertyKey?: string, descriptor?: PropertyDescriptor): any {
export function AfterUpdate(
target?: any,
propertyKey?: string,
descriptor?: PropertyDescriptor,
): any {
if (target && propertyKey && descriptor) {
registerHook(target, 'afterUpdate', descriptor.value);
} else {
return descriptor;
}
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Handle case where descriptor might be undefined
if (!descriptor) {
// For method decorators, we need to get the method from the target
const method = target[propertyKey];
if (typeof method === 'function') {
registerHook(target, 'afterUpdate', method);
}
return;
}
registerHook(target, 'afterUpdate', descriptor.value);
return descriptor;
};
}
}
export function BeforeDelete(target?: any, propertyKey?: string, descriptor?: PropertyDescriptor): any {
export function BeforeDelete(
target?: any,
propertyKey?: string,
descriptor?: PropertyDescriptor,
): any {
if (target && propertyKey && descriptor) {
registerHook(target, 'beforeDelete', descriptor.value);
} else {
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
registerHook(target, 'beforeDelete', descriptor.value);
};
}
return descriptor;
}
export function AfterDelete(target?: any, propertyKey?: string, descriptor?: PropertyDescriptor): any {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Handle case where descriptor might be undefined
if (!descriptor) {
// For method decorators, we need to get the method from the target
const method = target[propertyKey];
if (typeof method === 'function') {
registerHook(target, 'beforeDelete', method);
}
return;
}
registerHook(target, 'beforeDelete', descriptor.value);
return descriptor;
};
}
export function AfterDelete(
target?: any,
propertyKey?: string,
descriptor?: PropertyDescriptor,
): any {
if (target && propertyKey && descriptor) {
registerHook(target, 'afterDelete', descriptor.value);
} else {
return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
registerHook(target, 'afterDelete', descriptor.value);
};
}
return descriptor;
}
export function BeforeSave(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Handle case where descriptor might be undefined
if (!descriptor) {
// For method decorators, we need to get the method from the target
const method = target[propertyKey];
if (typeof method === 'function') {
registerHook(target, 'afterDelete', method);
}
return;
}
registerHook(target, 'afterDelete', descriptor.value);
return descriptor;
};
}
export function BeforeSave(
target?: any,
propertyKey?: string,
descriptor?: PropertyDescriptor,
): any {
if (target && propertyKey && descriptor) {
registerHook(target, 'beforeSave', descriptor.value);
return descriptor;
}
export function AfterSave(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Handle case where descriptor might be undefined
if (!descriptor) {
// For method decorators, we need to get the method from the target
const method = target[propertyKey];
if (typeof method === 'function') {
registerHook(target, 'beforeSave', method);
}
return;
}
registerHook(target, 'beforeSave', descriptor.value);
return descriptor;
};
}
export function AfterSave(
target?: any,
propertyKey?: string,
descriptor?: PropertyDescriptor,
): any {
if (target && propertyKey && descriptor) {
registerHook(target, 'afterSave', descriptor.value);
return descriptor;
}
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// Handle case where descriptor might be undefined
if (!descriptor) {
// For method decorators, we need to get the method from the target
const method = target[propertyKey];
if (typeof method === 'function') {
registerHook(target, 'afterSave', method);
}
return;
}
registerHook(target, 'afterSave', descriptor.value);
return descriptor;
};
}
function registerHook(target: any, hookName: string, hookFunction: Function): void {
@ -85,7 +217,8 @@ function registerHook(target: any, hookName: string, hookFunction: Function): vo
const existingHooks = target.constructor.hooks.get(hookName) || [];
// Add the new hook (store the function name for the tests)
existingHooks.push(hookFunction.name);
const functionName = hookFunction.name || 'anonymous';
existingHooks.push(functionName);
// Store updated hooks array
target.constructor.hooks.set(hookName, existingHooks);

View File

@ -17,7 +17,6 @@ export function BelongsTo(
targetModel: modelFactory, // Add targetModel as alias for test compatibility
};
registerRelationship(target, propertyKey, config);
createRelationshipProperty(target, propertyKey, config);
};
}
@ -39,7 +38,6 @@ export function HasMany(
targetModel: modelFactory, // Add targetModel as alias for test compatibility
};
registerRelationship(target, propertyKey, config);
createRelationshipProperty(target, propertyKey, config);
};
}
@ -60,7 +58,6 @@ export function HasOne(
targetModel: modelFactory, // Add targetModel as alias for test compatibility
};
registerRelationship(target, propertyKey, config);
createRelationshipProperty(target, propertyKey, config);
};
}
@ -85,35 +82,38 @@ export function ManyToMany(
targetModel: modelFactory, // Add targetModel as alias for test compatibility
};
registerRelationship(target, propertyKey, config);
createRelationshipProperty(target, propertyKey, config);
};
}
function registerRelationship(target: any, propertyKey: string, config: RelationshipConfig): void {
// Initialize relationships map if it doesn't exist on this specific constructor
if (!target.constructor.hasOwnProperty('relationships')) {
target.constructor.relationships = new Map();
}
// Store relationship configuration
target.constructor.relationships.set(propertyKey, config);
const modelName = config.model?.name || (config.modelFactory ? 'LazyModel' : 'UnknownModel');
console.log(
`Registered ${config.type} relationship: ${target.constructor.name}.${propertyKey} -> ${modelName}`,
);
}
function createRelationshipProperty(
target: any,
propertyKey: string,
config: RelationshipConfig,
): void {
const _relationshipKey = `_relationship_${propertyKey}`; // For future use
// In an ES module context, `target` can be undefined when decorators are first evaluated.
// We must ensure we only call Object.defineProperty on a valid object (the class prototype).
if (target) {
Object.defineProperty(target, propertyKey, {
get() {
const ctor = this.constructor as typeof BaseModel;
// One-time initialization of the relationships map on the constructor
if (!ctor.hasOwnProperty('relationships')) {
const parentRelationships = ctor.relationships ? new Map(ctor.relationships) : new Map();
Object.defineProperty(ctor, 'relationships', {
value: parentRelationships,
writable: true,
enumerable: false,
configurable: true,
});
}
// Store relationship configuration if it's not already there
if (!ctor.relationships.has(propertyKey)) {
ctor.relationships.set(propertyKey, config);
}
// Check if relationship is already loaded
if (this._loadedRelations && this._loadedRelations.has(propertyKey)) {
return this._loadedRelations.get(propertyKey);
@ -129,6 +129,24 @@ function createRelationshipProperty(
}
},
set(value) {
const ctor = this.constructor as typeof BaseModel;
// One-time initialization of the relationships map on the constructor
if (!ctor.hasOwnProperty('relationships')) {
const parentRelationships = ctor.relationships ? new Map(ctor.relationships) : new Map();
Object.defineProperty(ctor, 'relationships', {
value: parentRelationships,
writable: true,
enumerable: false,
configurable: true,
});
}
// Store relationship configuration if it's not already there
if (!ctor.relationships.has(propertyKey)) {
ctor.relationships.set(propertyKey, config);
}
// Allow manual setting of relationship values
if (!this._loadedRelations) {
this._loadedRelations = new Map();
@ -139,6 +157,7 @@ function createRelationshipProperty(
configurable: true,
});
}
}
// Utility function to get relationship configuration
export function getRelationshipConfig(
@ -146,7 +165,8 @@ export function getRelationshipConfig(
propertyKey?: string,
): RelationshipConfig | undefined | RelationshipConfig[] {
// Handle both class constructors and instances
const relationships = target.relationships || (target.constructor && target.constructor.relationships);
const relationships =
target.relationships || (target.constructor && target.constructor.relationships);
if (!relationships) {
return propertyKey ? undefined : [];
}

View File

@ -18,8 +18,9 @@ COPY package*.json pnpm-lock.yaml ./
# Install pnpm
RUN npm install -g pnpm
# Install full dependencies (needed for ts-node)
RUN pnpm install --frozen-lockfile --ignore-scripts
# Install full dependencies and reflect-metadata
RUN pnpm install --frozen-lockfile --ignore-scripts \
&& pnpm add reflect-metadata @babel/runtime
# Install tsx globally for running TypeScript files (better ESM support)
RUN npm install -g tsx
@ -40,5 +41,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Start the blog API server using tsx
CMD ["tsx", "tests/real-integration/blog-scenario/docker/blog-api-server.ts"]
# Start the blog API server using tsx with explicit tsconfig
CMD ["tsx", "--tsconfig", "tests/real-integration/blog-scenario/docker/tsconfig.docker.json", "tests/real-integration/blog-scenario/docker/blog-api-server.ts"]

View File

@ -40,22 +40,24 @@ class BlogAPIServer {
});
// Error handling
this.app.use((error: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
this.app.use(
(error: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error(`[${this.nodeId}] Error:`, error);
if (error instanceof ValidationError) {
return res.status(400).json({
error: error.message,
field: error.field,
nodeId: this.nodeId
nodeId: this.nodeId,
});
}
res.status(500).json({
error: 'Internal server error',
nodeId: this.nodeId
});
nodeId: this.nodeId,
});
},
);
}
private setupRoutes() {
@ -67,13 +69,13 @@ class BlogAPIServer {
status: 'healthy',
nodeId: this.nodeId,
peers,
timestamp: Date.now()
timestamp: Date.now(),
});
} catch (error) {
res.status(500).json({
status: 'unhealthy',
nodeId: this.nodeId,
error: error.message
error: error.message,
});
}
});
@ -109,7 +111,7 @@ class BlogAPIServer {
if (!user) {
return res.status(404).json({
error: 'User not found',
nodeId: this.nodeId
nodeId: this.nodeId,
});
}
res.json(user.toJSON());
@ -128,7 +130,8 @@ class BlogAPIServer {
let query = User.query();
if (search) {
query = query.where('username', 'like', `%${search}%`)
query = query
.where('username', 'like', `%${search}%`)
.orWhere('displayName', 'like', `%${search}%`);
}
@ -139,10 +142,10 @@ class BlogAPIServer {
.find();
res.json({
users: users.map(u => u.toJSON()),
users: users.map((u) => u.toJSON()),
page,
limit,
nodeId: this.nodeId
nodeId: this.nodeId,
});
} catch (error) {
next(error);
@ -156,7 +159,7 @@ class BlogAPIServer {
if (!user) {
return res.status(404).json({
error: 'User not found',
nodeId: this.nodeId
nodeId: this.nodeId,
});
}
@ -164,7 +167,7 @@ class BlogAPIServer {
const allowedFields = ['displayName', 'avatar', 'roles'];
const updateData: any = {};
allowedFields.forEach(field => {
allowedFields.forEach((field) => {
if (req.body[field] !== undefined) {
updateData[field] = req.body[field];
}
@ -187,7 +190,7 @@ class BlogAPIServer {
if (!user) {
return res.status(404).json({
error: 'User not found',
nodeId: this.nodeId
nodeId: this.nodeId,
});
}
@ -225,7 +228,7 @@ class BlogAPIServer {
res.json({
categories,
nodeId: this.nodeId
nodeId: this.nodeId,
});
} catch (error) {
next(error);
@ -239,7 +242,7 @@ class BlogAPIServer {
if (!category) {
return res.status(404).json({
error: 'Category not found',
nodeId: this.nodeId
nodeId: this.nodeId,
});
}
res.json(category);
@ -276,7 +279,7 @@ class BlogAPIServer {
if (!post) {
return res.status(404).json({
error: 'Post not found',
nodeId: this.nodeId
nodeId: this.nodeId,
});
}
@ -324,7 +327,7 @@ class BlogAPIServer {
posts,
page,
limit,
nodeId: this.nodeId
nodeId: this.nodeId,
});
} catch (error) {
next(error);
@ -338,7 +341,7 @@ class BlogAPIServer {
if (!post) {
return res.status(404).json({
error: 'Post not found',
nodeId: this.nodeId
nodeId: this.nodeId,
});
}
@ -362,7 +365,7 @@ class BlogAPIServer {
if (!post) {
return res.status(404).json({
error: 'Post not found',
nodeId: this.nodeId
nodeId: this.nodeId,
});
}
@ -381,7 +384,7 @@ class BlogAPIServer {
if (!post) {
return res.status(404).json({
error: 'Post not found',
nodeId: this.nodeId
nodeId: this.nodeId,
});
}
@ -400,7 +403,7 @@ class BlogAPIServer {
if (!post) {
return res.status(404).json({
error: 'Post not found',
nodeId: this.nodeId
nodeId: this.nodeId,
});
}
@ -418,7 +421,7 @@ class BlogAPIServer {
if (!post) {
return res.status(404).json({
error: 'Post not found',
nodeId: this.nodeId
nodeId: this.nodeId,
});
}
@ -439,7 +442,9 @@ class BlogAPIServer {
const comment = await Comment.create(sanitizedData);
console.log(`[${this.nodeId}] Created comment on post ${comment.postId} by ${comment.authorId}`);
console.log(
`[${this.nodeId}] Created comment on post ${comment.postId} by ${comment.authorId}`,
);
res.status(201).json(comment);
} catch (error) {
next(error);
@ -458,7 +463,7 @@ class BlogAPIServer {
res.json({
comments,
nodeId: this.nodeId
nodeId: this.nodeId,
});
} catch (error) {
next(error);
@ -472,7 +477,7 @@ class BlogAPIServer {
if (!comment) {
return res.status(404).json({
error: 'Comment not found',
nodeId: this.nodeId
nodeId: this.nodeId,
});
}
@ -491,7 +496,7 @@ class BlogAPIServer {
if (!comment) {
return res.status(404).json({
error: 'Comment not found',
nodeId: this.nodeId
nodeId: this.nodeId,
});
}
@ -511,7 +516,7 @@ class BlogAPIServer {
res.json({
nodeId: this.nodeId,
peers,
timestamp: Date.now()
timestamp: Date.now(),
});
} catch (error) {
next(error);
@ -525,7 +530,7 @@ class BlogAPIServer {
User.count(),
Post.count(),
Comment.count(),
Category.count()
Category.count(),
]);
res.json({
@ -534,9 +539,9 @@ class BlogAPIServer {
users: userCount,
posts: postCount,
comments: commentCount,
categories: categoryCount
categories: categoryCount,
},
timestamp: Date.now()
timestamp: Date.now(),
});
} catch (error) {
next(error);
@ -550,7 +555,7 @@ class BlogAPIServer {
res.json({
nodeId: this.nodeId,
...metrics,
timestamp: Date.now()
timestamp: Date.now(),
});
} catch (error) {
next(error);
@ -590,7 +595,6 @@ class BlogAPIServer {
console.log(`[${this.nodeId}] Blog API server listening on port ${port}`);
console.log(`[${this.nodeId}] Health check: http://localhost:${port}/health`);
});
} catch (error) {
console.error(`[${this.nodeId}] Failed to start:`, error);
process.exit(1);
@ -605,16 +609,20 @@ class BlogAPIServer {
private async initializeFramework(): Promise<void> {
// Import services
const { IPFSService } = await import('../../../../src/framework/services/IPFSService');
const { OrbitDBService } = await import('../../../../src/framework/services/RealOrbitDBService');
const { FrameworkIPFSService, FrameworkOrbitDBService } = await import('../../../../src/framework/services/OrbitDBService');
const { OrbitDBService } = await import(
'../../../../src/framework/services/RealOrbitDBService'
);
const { FrameworkIPFSService, FrameworkOrbitDBService } = await import(
'../../../../src/framework/services/OrbitDBService'
);
// Initialize IPFS service
const ipfsService = new IPFSService({
swarmKeyFile: process.env.SWARM_KEY_FILE,
bootstrap: process.env.BOOTSTRAP_PEER ? [`/ip4/${process.env.BOOTSTRAP_PEER}/tcp/4001`] : [],
ports: {
swarm: parseInt(process.env.IPFS_PORT) || 4001
}
swarm: parseInt(process.env.IPFS_PORT) || 4001,
},
});
await ipfsService.init();
@ -637,13 +645,13 @@ class BlogAPIServer {
automaticPinning: true,
pubsub: true,
queryCache: true,
relationshipCache: true
relationshipCache: true,
},
performance: {
queryTimeout: 10000,
maxConcurrentOperations: 20,
batchSize: 50
}
batchSize: 50,
},
});
await this.framework.initialize(frameworkOrbitDB, frameworkIPFS);

View File

@ -1,3 +1,4 @@
import 'reflect-metadata';
import { BaseModel } from '../../../../src/framework/models/BaseModel';
import { Model, Field, HasMany, BelongsTo, HasOne, BeforeCreate, AfterCreate } from '../../../../src/framework/models/decorators';
@ -326,9 +327,6 @@ export class Comment extends BaseModel {
}
}
// Export all models
export { User, UserProfile, Category, Post, Comment };
// Type definitions for API requests
export interface CreateUserRequest {
username: string;