@hiliosai/sdk 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/datasources/base.datasource.ts +37 -5
- package/src/datasources/extensions/index.ts +11 -0
- package/src/datasources/extensions/retry.extension.ts +91 -0
- package/src/datasources/extensions/soft-delete.extension.ts +113 -0
- package/src/datasources/extensions/tenant.extension.ts +104 -0
- package/src/datasources/index.ts +2 -0
- package/src/datasources/prisma.datasource.ts +325 -0
- package/src/index.ts +2 -0
- package/src/mixins/datasource.mixin.ts +17 -24
package/package.json
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
|
+
import type {ServiceBroker} from 'moleculer';
|
|
2
|
+
|
|
3
|
+
import type {AppContext} from '../types/context';
|
|
4
|
+
|
|
1
5
|
/**
|
|
2
6
|
* Base datasource interface that all datasources should implement
|
|
3
7
|
* Provides common structure and optional lifecycle methods
|
|
4
8
|
*/
|
|
5
|
-
export interface BaseDatasource {
|
|
9
|
+
export interface BaseDatasource<TContext = AppContext> {
|
|
6
10
|
/**
|
|
7
11
|
* Datasource name for identification and logging
|
|
8
12
|
*/
|
|
9
13
|
readonly name: string;
|
|
10
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Service broker instance for logging, events, and service calls
|
|
17
|
+
* Always available after datasource initialization
|
|
18
|
+
*/
|
|
19
|
+
broker: ServiceBroker;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Current service context (only available during action execution)
|
|
23
|
+
* Will be undefined in lifecycle hooks or background tasks
|
|
24
|
+
*/
|
|
25
|
+
context?: TContext;
|
|
26
|
+
|
|
11
27
|
/**
|
|
12
28
|
* Optional initialization method
|
|
13
29
|
* Called when datasource is instantiated
|
|
@@ -48,24 +64,40 @@ export interface BaseDatasource {
|
|
|
48
64
|
* private users: User[] = [];
|
|
49
65
|
*
|
|
50
66
|
* async findById(id: string): Promise<User | undefined> {
|
|
67
|
+
* this.broker?.logger.info('Finding user by ID:', id);
|
|
51
68
|
* return this.users.find(u => u.id === id);
|
|
52
69
|
* }
|
|
53
70
|
*
|
|
54
|
-
* async
|
|
55
|
-
* this.
|
|
56
|
-
* return
|
|
71
|
+
* async healthCheck(): Promise<boolean> {
|
|
72
|
+
* this.broker?.logger.info('Health check for datasource', this.name);
|
|
73
|
+
* return true;
|
|
57
74
|
* }
|
|
58
75
|
* }
|
|
59
76
|
* ```
|
|
60
77
|
*/
|
|
61
|
-
export abstract class AbstractDatasource
|
|
78
|
+
export abstract class AbstractDatasource<TContext = AppContext>
|
|
79
|
+
implements BaseDatasource<TContext>
|
|
80
|
+
{
|
|
62
81
|
abstract readonly name: string;
|
|
63
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Service broker instance - injected by mixin
|
|
85
|
+
* Always available after initialization
|
|
86
|
+
*/
|
|
87
|
+
broker!: ServiceBroker; // Using definite assignment assertion since it's injected
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Current service context - injected per action
|
|
91
|
+
* Only available during action execution
|
|
92
|
+
*/
|
|
93
|
+
context?: TContext;
|
|
94
|
+
|
|
64
95
|
/**
|
|
65
96
|
* Default health check - always returns true
|
|
66
97
|
* Override for custom health check logic
|
|
67
98
|
*/
|
|
68
99
|
async healthCheck(): Promise<boolean> {
|
|
100
|
+
this.broker.logger.info(`Health check for datasource - ${this.name}`);
|
|
69
101
|
return true;
|
|
70
102
|
}
|
|
71
103
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Export all Prisma extensions for easy importing
|
|
2
|
+
export { softDeleteExtension } from './soft-delete.extension';
|
|
3
|
+
export { createTenantExtension } from './tenant.extension';
|
|
4
|
+
export { retryExtension } from './retry.extension';
|
|
5
|
+
|
|
6
|
+
// Re-export extension interfaces
|
|
7
|
+
export type {
|
|
8
|
+
SoftDeleteExtension,
|
|
9
|
+
AuditTrailExtension,
|
|
10
|
+
TenantExtension,
|
|
11
|
+
} from '../prisma.datasource';
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
/**
|
|
3
|
+
* Retry Extension for Prisma Transactions
|
|
4
|
+
* Automatically retries failed transactions with exponential backoff
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```typescript
|
|
8
|
+
* import { retryExtension } from './extensions/retry.extension';
|
|
9
|
+
*
|
|
10
|
+
* class MyPrismaDS extends PrismaDatasource<PrismaClient> {
|
|
11
|
+
* protected applyExtensions(client: PrismaClient) {
|
|
12
|
+
* return client.$extends(retryExtension);
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const retryExtension = {
|
|
19
|
+
name: 'Retry',
|
|
20
|
+
|
|
21
|
+
client: {
|
|
22
|
+
async $retryTransaction<T>(
|
|
23
|
+
fn: (tx: any) => Promise<T>,
|
|
24
|
+
options: {
|
|
25
|
+
maxRetries?: number;
|
|
26
|
+
baseDelay?: number;
|
|
27
|
+
maxDelay?: number;
|
|
28
|
+
retryableErrors?: string[];
|
|
29
|
+
} = {}
|
|
30
|
+
): Promise<T> {
|
|
31
|
+
const {
|
|
32
|
+
maxRetries = 3,
|
|
33
|
+
baseDelay = 100,
|
|
34
|
+
maxDelay = 5000,
|
|
35
|
+
retryableErrors = [
|
|
36
|
+
'P2034', // Transaction failed due to a write conflict
|
|
37
|
+
'P2002', // Unique constraint failed
|
|
38
|
+
'P5000', // Raw query failed
|
|
39
|
+
],
|
|
40
|
+
} = options;
|
|
41
|
+
|
|
42
|
+
let lastError: Error | undefined;
|
|
43
|
+
|
|
44
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
45
|
+
try {
|
|
46
|
+
return await (this as any).$transaction(fn, {
|
|
47
|
+
timeout: 10000,
|
|
48
|
+
maxWait: 5000,
|
|
49
|
+
});
|
|
50
|
+
} catch (error: any) {
|
|
51
|
+
lastError = error;
|
|
52
|
+
|
|
53
|
+
// Check if error is retryable
|
|
54
|
+
const isRetryable = retryableErrors.some(
|
|
55
|
+
(code) => error.code === code || error.message?.includes(code)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (!isRetryable || attempt === maxRetries) {
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Calculate delay with exponential backoff and jitter
|
|
63
|
+
const delay = Math.min(
|
|
64
|
+
baseDelay * Math.pow(2, attempt) + Math.random() * 100,
|
|
65
|
+
maxDelay
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Log retry attempt (in production, use proper logger)
|
|
69
|
+
if (
|
|
70
|
+
typeof globalThis !== 'undefined' &&
|
|
71
|
+
(globalThis as any).console
|
|
72
|
+
) {
|
|
73
|
+
(globalThis as any).console.warn(
|
|
74
|
+
`Transaction retry ${
|
|
75
|
+
attempt + 1
|
|
76
|
+
}/${maxRetries} after ${delay}ms:`,
|
|
77
|
+
{
|
|
78
|
+
error: error.message,
|
|
79
|
+
code: error.code,
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
throw lastError ?? new Error('Transaction failed after maximum retries');
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soft Delete Extension for Prisma
|
|
3
|
+
* Automatically handles soft deletes by setting deletedAt instead of removing records
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* ```typescript
|
|
7
|
+
* import { softDeleteExtension } from './extensions/soft-delete.extension';
|
|
8
|
+
*
|
|
9
|
+
* class MyPrismaDS extends PrismaDatasource<PrismaClient> {
|
|
10
|
+
* protected applyExtensions(client: PrismaClient) {
|
|
11
|
+
* return client.$extends(softDeleteExtension);
|
|
12
|
+
* }
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Requires models to have `deletedAt DateTime?` field
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const softDeleteExtension = {
|
|
20
|
+
name: 'SoftDelete',
|
|
21
|
+
|
|
22
|
+
query: {
|
|
23
|
+
// Apply to all models
|
|
24
|
+
$allModels: {
|
|
25
|
+
// Override findMany to exclude soft deleted records
|
|
26
|
+
async findMany({args, query}: any) {
|
|
27
|
+
// Add deletedAt: null filter if not already specified
|
|
28
|
+
args.where ??= {};
|
|
29
|
+
if (args.where.deletedAt === undefined) {
|
|
30
|
+
args.where.deletedAt = null;
|
|
31
|
+
}
|
|
32
|
+
return query(args);
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// Override findUnique to exclude soft deleted records
|
|
36
|
+
async findUnique({args, query}: any) {
|
|
37
|
+
args.where ??= {};
|
|
38
|
+
if (args.where.deletedAt === undefined) {
|
|
39
|
+
args.where.deletedAt = null;
|
|
40
|
+
}
|
|
41
|
+
return query(args);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// Override findFirst to exclude soft deleted records
|
|
45
|
+
async findFirst({args, query}: any) {
|
|
46
|
+
args.where ??= {};
|
|
47
|
+
if (args.where.deletedAt === undefined) {
|
|
48
|
+
args.where.deletedAt = null;
|
|
49
|
+
}
|
|
50
|
+
return query(args);
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
// Override delete to set deletedAt instead
|
|
54
|
+
async delete({args, query}: any) {
|
|
55
|
+
return query({
|
|
56
|
+
...args,
|
|
57
|
+
data: {deletedAt: new Date()},
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
// Override deleteMany to set deletedAt instead
|
|
62
|
+
async deleteMany({args, query}: any) {
|
|
63
|
+
return query({
|
|
64
|
+
...args,
|
|
65
|
+
data: {deletedAt: new Date()},
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
model: {
|
|
72
|
+
$allModels: {
|
|
73
|
+
// Add restore method to all models
|
|
74
|
+
async restore<T>(this: T, where: any): Promise<any> {
|
|
75
|
+
const context =
|
|
76
|
+
(globalThis as any).Prisma?.getExtensionContext?.(this) ?? this;
|
|
77
|
+
|
|
78
|
+
return (context as any).update({
|
|
79
|
+
where,
|
|
80
|
+
data: {deletedAt: null},
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// Add findWithDeleted method to include soft deleted records
|
|
85
|
+
async findWithDeleted<T>(this: T, args: any): Promise<any> {
|
|
86
|
+
const context =
|
|
87
|
+
(globalThis as any).Prisma?.getExtensionContext?.(this) ?? this;
|
|
88
|
+
|
|
89
|
+
return (context as any).findMany({
|
|
90
|
+
...args,
|
|
91
|
+
where: {
|
|
92
|
+
...args.where,
|
|
93
|
+
// Don't filter by deletedAt
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
// Add findDeleted method to find only soft deleted records
|
|
99
|
+
async findDeleted<T>(this: T, args: any): Promise<any> {
|
|
100
|
+
const context =
|
|
101
|
+
(globalThis as any).Prisma?.getExtensionContext?.(this) ?? this;
|
|
102
|
+
|
|
103
|
+
return (context as any).findMany({
|
|
104
|
+
...args,
|
|
105
|
+
where: {
|
|
106
|
+
...args.where,
|
|
107
|
+
deletedAt: {not: null},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Tenant Extension for Prisma
|
|
3
|
+
* Automatically filters queries by tenant context for multi-tenant applications
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* ```typescript
|
|
7
|
+
* import { createTenantExtension } from './extensions/tenant.extension';
|
|
8
|
+
*
|
|
9
|
+
* class MyPrismaDS extends PrismaDatasource<PrismaClient> {
|
|
10
|
+
* protected applyExtensions(client: PrismaClient) {
|
|
11
|
+
* return client.$extends(createTenantExtension());
|
|
12
|
+
* }
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* Requires models to have `tenantId String` field
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
interface TenantContext {
|
|
20
|
+
currentTenantId: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createTenantExtension() {
|
|
24
|
+
const tenantContext: TenantContext = {
|
|
25
|
+
currentTenantId: null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
name: 'Tenant',
|
|
30
|
+
|
|
31
|
+
client: {
|
|
32
|
+
// Set tenant context
|
|
33
|
+
$setTenant(tenantId: string) {
|
|
34
|
+
tenantContext.currentTenantId = tenantId;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// Get current tenant
|
|
38
|
+
$getCurrentTenant() {
|
|
39
|
+
return tenantContext.currentTenantId;
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// Clear tenant context
|
|
43
|
+
$clearTenant() {
|
|
44
|
+
tenantContext.currentTenantId = null;
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
query: {
|
|
49
|
+
$allModels: {
|
|
50
|
+
// Automatically add tenantId filter to all read operations
|
|
51
|
+
async findMany({ args, query }: any) {
|
|
52
|
+
if (tenantContext.currentTenantId) {
|
|
53
|
+
args.where ??= {};
|
|
54
|
+
args.where.tenantId ??= tenantContext.currentTenantId;
|
|
55
|
+
}
|
|
56
|
+
return query(args);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async findUnique({ args, query }: any) {
|
|
60
|
+
if (tenantContext.currentTenantId) {
|
|
61
|
+
args.where ??= {};
|
|
62
|
+
args.where.tenantId ??= tenantContext.currentTenantId;
|
|
63
|
+
}
|
|
64
|
+
return query(args);
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async findFirst({ args, query }: any) {
|
|
68
|
+
if (tenantContext.currentTenantId) {
|
|
69
|
+
args.where ??= {};
|
|
70
|
+
args.where.tenantId ??= tenantContext.currentTenantId;
|
|
71
|
+
}
|
|
72
|
+
return query(args);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// Automatically add tenantId to create operations
|
|
76
|
+
async create({ args, query }: any) {
|
|
77
|
+
if (tenantContext.currentTenantId) {
|
|
78
|
+
args.data ??= {};
|
|
79
|
+
args.data.tenantId ??= tenantContext.currentTenantId;
|
|
80
|
+
}
|
|
81
|
+
return query(args);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
// Add tenantId filter to update operations
|
|
85
|
+
async update({ args, query }: any) {
|
|
86
|
+
if (tenantContext.currentTenantId) {
|
|
87
|
+
args.where ??= {};
|
|
88
|
+
args.where.tenantId ??= tenantContext.currentTenantId;
|
|
89
|
+
}
|
|
90
|
+
return query(args);
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
// Add tenantId filter to delete operations
|
|
94
|
+
async delete({ args, query }: any) {
|
|
95
|
+
if (tenantContext.currentTenantId) {
|
|
96
|
+
args.where ??= {};
|
|
97
|
+
args.where.tenantId ??= tenantContext.currentTenantId;
|
|
98
|
+
}
|
|
99
|
+
return query(args);
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/datasources/index.ts
CHANGED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import {isDev, isTest} from '../env';
|
|
3
|
+
import type {AppContext} from '../types/context';
|
|
4
|
+
import {AbstractDatasource} from './base.datasource';
|
|
5
|
+
|
|
6
|
+
// Generic Prisma Client interface - will be provided by generated client
|
|
7
|
+
interface PrismaClientLike {
|
|
8
|
+
$connect(): Promise<void>;
|
|
9
|
+
$disconnect(): Promise<void>;
|
|
10
|
+
$queryRaw(
|
|
11
|
+
query: TemplateStringsArray,
|
|
12
|
+
...values: unknown[]
|
|
13
|
+
): Promise<unknown>;
|
|
14
|
+
$transaction<T>(
|
|
15
|
+
fn: (client: PrismaClientLike) => Promise<T>,
|
|
16
|
+
options?: {
|
|
17
|
+
maxWait?: number;
|
|
18
|
+
timeout?: number;
|
|
19
|
+
}
|
|
20
|
+
): Promise<T>;
|
|
21
|
+
$extends<T>(extension: T): PrismaClientLike & T;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Extension interfaces for common production patterns
|
|
26
|
+
export interface SoftDeleteExtension {
|
|
27
|
+
// Adds soft delete functionality to models
|
|
28
|
+
softDelete: {
|
|
29
|
+
[model: string]: {
|
|
30
|
+
delete: (args: any) => Promise<any>;
|
|
31
|
+
deleteMany: (args: any) => Promise<any>;
|
|
32
|
+
restore: (args: any) => Promise<any>;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AuditTrailExtension {
|
|
38
|
+
// Adds audit trail functionality
|
|
39
|
+
$auditTrail: {
|
|
40
|
+
getHistory: (model: string, id: string) => Promise<any[]>;
|
|
41
|
+
getChanges: (
|
|
42
|
+
model: string,
|
|
43
|
+
id: string,
|
|
44
|
+
from?: Date,
|
|
45
|
+
to?: Date
|
|
46
|
+
) => Promise<any[]>;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TenantExtension {
|
|
51
|
+
// Adds multi-tenant filtering
|
|
52
|
+
$tenant: {
|
|
53
|
+
setContext: (tenantId: string) => void;
|
|
54
|
+
getCurrentTenant: () => string | null;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Global singleton pattern for Prisma Client
|
|
59
|
+
declare global {
|
|
60
|
+
var __prisma: PrismaClientLike | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Prisma datasource that follows singleton pattern and best practices
|
|
65
|
+
* Supports both provided client instances and automatic initialization
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```typescript
|
|
69
|
+
* // Option 1: Let datasource create singleton client
|
|
70
|
+
* datasources: {
|
|
71
|
+
* prisma: PrismaDatasource
|
|
72
|
+
* }
|
|
73
|
+
*
|
|
74
|
+
* // Option 2: Provide custom client
|
|
75
|
+
* const customClient = new PrismaClient({...});
|
|
76
|
+
* datasources: {
|
|
77
|
+
* prisma: () => new PrismaDatasource(customClient)
|
|
78
|
+
* }
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export class PrismaDatasource<
|
|
82
|
+
TPrismaClient extends PrismaClientLike = PrismaClientLike,
|
|
83
|
+
TContext = AppContext
|
|
84
|
+
> extends AbstractDatasource<TContext> {
|
|
85
|
+
readonly name = 'prisma';
|
|
86
|
+
private _client: TPrismaClient | null = null;
|
|
87
|
+
private providedClient: TPrismaClient | null = null;
|
|
88
|
+
|
|
89
|
+
constructor(prismaClient?: TPrismaClient) {
|
|
90
|
+
super();
|
|
91
|
+
this.providedClient = prismaClient ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get Prisma client instance (singleton pattern)
|
|
96
|
+
*/
|
|
97
|
+
get client(): TPrismaClient {
|
|
98
|
+
this._client ??= this.initializePrismaClient();
|
|
99
|
+
|
|
100
|
+
// Update tenant context from current service context
|
|
101
|
+
this.updateTenantFromContext();
|
|
102
|
+
|
|
103
|
+
return this._client;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Initialize Prisma client using singleton pattern or provided instance
|
|
108
|
+
*/
|
|
109
|
+
private initializePrismaClient(): TPrismaClient {
|
|
110
|
+
// Option 1: Use provided client instance
|
|
111
|
+
if (this.providedClient) {
|
|
112
|
+
this.broker.logger.info('Using provided PrismaClient instance');
|
|
113
|
+
return this.providedClient;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Option 2: Use global singleton (recommended for most cases)
|
|
117
|
+
if (!globalThis.__prisma) {
|
|
118
|
+
this.broker.logger.info('Creating new PrismaClient singleton');
|
|
119
|
+
const baseClient = this.createClient();
|
|
120
|
+
globalThis.__prisma = this.applyExtensions(baseClient);
|
|
121
|
+
} else {
|
|
122
|
+
this.broker.logger.info('Using existing PrismaClient singleton');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return globalThis.__prisma as TPrismaClient;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Create a new Prisma client instance
|
|
130
|
+
* Override this method in subclasses to provide the actual PrismaClient
|
|
131
|
+
*/
|
|
132
|
+
protected createClient(): TPrismaClient {
|
|
133
|
+
throw new Error(
|
|
134
|
+
'createClient() must be implemented by subclass or provide PrismaClient in constructor. ' +
|
|
135
|
+
'Example: class MyPrismaDataSource extends PrismaDatasource { ' +
|
|
136
|
+
'protected createClient() { return new PrismaClient(); } }'
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Apply extensions to the Prisma client
|
|
142
|
+
* Override this method to add production extensions like soft delete, audit trails, etc.
|
|
143
|
+
*/
|
|
144
|
+
protected applyExtensions(client: TPrismaClient): TPrismaClient {
|
|
145
|
+
// Base implementation returns client as-is
|
|
146
|
+
// Override in subclass to add extensions:
|
|
147
|
+
// return client.$extends(softDeleteExtension).$extends(auditExtension)
|
|
148
|
+
return client;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get extended client with all applied extensions
|
|
153
|
+
*/
|
|
154
|
+
get extendedClient(): TPrismaClient {
|
|
155
|
+
return this.applyExtensions(this.client);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Initialize datasource - called after broker injection
|
|
160
|
+
*/
|
|
161
|
+
async init(): Promise<void> {
|
|
162
|
+
this.broker.logger.info('Initializing Prisma datasource');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Called automatically when context is injected
|
|
167
|
+
* Sets tenant context from service context if available
|
|
168
|
+
*/
|
|
169
|
+
private updateTenantFromContext(): void {
|
|
170
|
+
const tenantId = (this.context as any)?.meta?.tenantId;
|
|
171
|
+
if (tenantId && typeof tenantId === 'string') {
|
|
172
|
+
this.setTenantContext(tenantId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Connect to database - called when service starts
|
|
178
|
+
*/
|
|
179
|
+
async connect(): Promise<void> {
|
|
180
|
+
try {
|
|
181
|
+
this.broker.logger.info('Connecting to database via Prisma');
|
|
182
|
+
await this.client.$connect();
|
|
183
|
+
this.broker.logger.info('Successfully connected to database');
|
|
184
|
+
} catch (error) {
|
|
185
|
+
this.broker.logger.error('Failed to connect to database:', error);
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Disconnect from database - called when service stops
|
|
192
|
+
*/
|
|
193
|
+
async disconnect(): Promise<void> {
|
|
194
|
+
try {
|
|
195
|
+
this.broker.logger.info('Disconnecting from database');
|
|
196
|
+
await this.client.$disconnect();
|
|
197
|
+
this.broker.logger.info('Successfully disconnected from database');
|
|
198
|
+
} catch (error) {
|
|
199
|
+
this.broker.logger.error('Error disconnecting from database:', error);
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Health check - verify database connectivity
|
|
206
|
+
*/
|
|
207
|
+
async healthCheck(): Promise<boolean> {
|
|
208
|
+
try {
|
|
209
|
+
this.broker.logger.info('Running Prisma health check');
|
|
210
|
+
|
|
211
|
+
// Simple connectivity test
|
|
212
|
+
await this.client.$queryRaw`SELECT 1`;
|
|
213
|
+
|
|
214
|
+
this.broker.logger.info('Prisma health check passed');
|
|
215
|
+
return true;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
this.broker.logger.error('Prisma health check failed:', error);
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Clear/reset data - useful for testing
|
|
224
|
+
*/
|
|
225
|
+
async clear(): Promise<void> {
|
|
226
|
+
if (isTest || isDev) {
|
|
227
|
+
this.broker.logger.warn(
|
|
228
|
+
'Clearing database (only allowed in test/dev mode)'
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// Get all model names dynamically
|
|
232
|
+
const modelNames = Object.keys(this.client).filter(
|
|
233
|
+
(key) => !key.startsWith('_') && !key.startsWith('$')
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// Delete all data in reverse order (to handle foreign keys)
|
|
237
|
+
for (const modelName of modelNames.reverse()) {
|
|
238
|
+
try {
|
|
239
|
+
const model = this.client[modelName as keyof TPrismaClient] as {
|
|
240
|
+
deleteMany?: () => Promise<unknown>;
|
|
241
|
+
};
|
|
242
|
+
if (model.deleteMany) {
|
|
243
|
+
await model.deleteMany();
|
|
244
|
+
}
|
|
245
|
+
} catch (error) {
|
|
246
|
+
// Some models might not support deleteMany, skip them
|
|
247
|
+
this.broker.logger.debug(`Could not clear ${modelName}:`, error);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
throw new Error(
|
|
252
|
+
'Database clear is only allowed in test/development environments'
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Transaction wrapper with proper error handling
|
|
259
|
+
*/
|
|
260
|
+
async transaction<T>(
|
|
261
|
+
fn: (tx: TPrismaClient) => Promise<T>,
|
|
262
|
+
options?: {
|
|
263
|
+
maxWait?: number;
|
|
264
|
+
timeout?: number;
|
|
265
|
+
}
|
|
266
|
+
): Promise<T> {
|
|
267
|
+
try {
|
|
268
|
+
return await this.client.$transaction(
|
|
269
|
+
(tx) => fn(tx as TPrismaClient),
|
|
270
|
+
options
|
|
271
|
+
);
|
|
272
|
+
} catch (error) {
|
|
273
|
+
this.broker.logger.error('Transaction failed:', error);
|
|
274
|
+
throw error;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Set tenant context for multi-tenant applications
|
|
280
|
+
* Requires tenant extension to be applied
|
|
281
|
+
*/
|
|
282
|
+
setTenantContext(tenantId: string): void {
|
|
283
|
+
const tenantClient = this.client as any;
|
|
284
|
+
if (tenantClient.$setTenant) {
|
|
285
|
+
tenantClient.$setTenant(tenantId);
|
|
286
|
+
this.broker.logger.debug('Tenant context set:', {tenantId});
|
|
287
|
+
} else {
|
|
288
|
+
this.broker.logger.warn(
|
|
289
|
+
'Tenant extension not available - setTenantContext ignored'
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get current tenant context
|
|
296
|
+
*/
|
|
297
|
+
getCurrentTenant(): string | null {
|
|
298
|
+
const tenantClient = this.client as any;
|
|
299
|
+
if (tenantClient.$getCurrentTenant) {
|
|
300
|
+
return tenantClient.$getCurrentTenant();
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Execute with tenant context (automatically restores previous context)
|
|
307
|
+
*/
|
|
308
|
+
async withTenant<T>(tenantId: string, fn: () => Promise<T>): Promise<T> {
|
|
309
|
+
const previousTenant = this.getCurrentTenant();
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
this.setTenantContext(tenantId);
|
|
313
|
+
return await fn();
|
|
314
|
+
} finally {
|
|
315
|
+
if (previousTenant) {
|
|
316
|
+
this.setTenantContext(previousTenant);
|
|
317
|
+
} else {
|
|
318
|
+
const tenantClient = this.client as any;
|
|
319
|
+
if (tenantClient.$clearTenant) {
|
|
320
|
+
tenantClient.$clearTenant();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,23 +1,11 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import type {ServiceSchema} from 'moleculer';
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
DatasourceConstructorRegistry,
|
|
5
|
+
DatasourceInstanceRegistry,
|
|
6
|
+
} from '../middlewares/datasource.middleware';
|
|
4
7
|
import type {AppContext} from '../types/context';
|
|
5
8
|
|
|
6
|
-
/**
|
|
7
|
-
* Datasource constructor registry type
|
|
8
|
-
* All datasources should implement BaseDatasource interface
|
|
9
|
-
*/
|
|
10
|
-
export interface DatasourceConstructorRegistry {
|
|
11
|
-
[key: string]: new () => BaseDatasource | object;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Datasource instance registry type
|
|
16
|
-
*/
|
|
17
|
-
export interface DatasourceInstanceRegistry {
|
|
18
|
-
[key: string]: object;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
9
|
/**
|
|
22
10
|
* Creates a Moleculer mixin for datasource injection
|
|
23
11
|
* Simple mixin that just instantiates datasources and injects them into context
|
|
@@ -59,6 +47,11 @@ export function DatasourceMixin(
|
|
|
59
47
|
* Initialize datasources and store on service
|
|
60
48
|
*/
|
|
61
49
|
async created() {
|
|
50
|
+
// Inject broker into datasources
|
|
51
|
+
for (const [, datasource] of Object.entries(datasourceInstances)) {
|
|
52
|
+
(datasource as any).broker = this.broker;
|
|
53
|
+
}
|
|
54
|
+
|
|
62
55
|
// Call init() on datasources that have it
|
|
63
56
|
for (const [, datasource] of Object.entries(datasourceInstances)) {
|
|
64
57
|
if (typeof (datasource as any).init === 'function') {
|
|
@@ -102,17 +95,17 @@ export function DatasourceMixin(
|
|
|
102
95
|
hooks: {
|
|
103
96
|
before: {
|
|
104
97
|
'*': function injectDatasources(ctx) {
|
|
98
|
+
const datasources = (this as any).$datasources ?? {};
|
|
99
|
+
|
|
100
|
+
// Inject current context into all datasources
|
|
101
|
+
for (const [, datasource] of Object.entries(datasources)) {
|
|
102
|
+
(datasource as any).context = ctx;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
105
|
// Inject datasources into the context
|
|
106
|
-
(ctx as AppContext).datasources =
|
|
106
|
+
(ctx as AppContext).datasources = datasources;
|
|
107
107
|
},
|
|
108
108
|
},
|
|
109
109
|
},
|
|
110
110
|
};
|
|
111
111
|
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Type helper for services that use the datasource mixin
|
|
115
|
-
*/
|
|
116
|
-
export type ServiceWithDatasources<T extends DatasourceInstanceRegistry> = {
|
|
117
|
-
datasources: T;
|
|
118
|
-
};
|