@hiliosai/sdk 0.1.4 → 0.1.6

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.
@@ -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
+ }
@@ -59,6 +59,11 @@ export function DatasourceMixin(
59
59
  * Initialize datasources and store on service
60
60
  */
61
61
  async created() {
62
+ // Inject broker into datasources
63
+ for (const [, datasource] of Object.entries(datasourceInstances)) {
64
+ (datasource as any).broker = this.broker;
65
+ }
66
+
62
67
  // Call init() on datasources that have it
63
68
  for (const [, datasource] of Object.entries(datasourceInstances)) {
64
69
  if (typeof (datasource as any).init === 'function') {
@@ -102,8 +107,15 @@ export function DatasourceMixin(
102
107
  hooks: {
103
108
  before: {
104
109
  '*': function injectDatasources(ctx) {
110
+ const datasources = (this as any).$datasources ?? {};
111
+
112
+ // Inject current context into all datasources
113
+ for (const [, datasource] of Object.entries(datasources)) {
114
+ (datasource as any).context = ctx;
115
+ }
116
+
105
117
  // Inject datasources into the context
106
- (ctx as AppContext).datasources = (this as any).$datasources ?? {};
118
+ (ctx as AppContext).datasources = datasources;
107
119
  },
108
120
  },
109
121
  },
@@ -1,18 +1,25 @@
1
1
  import crypto from 'crypto';
2
2
 
3
- import {defineService} from './define-service';
3
+ import {CHANNEL_CONFIG, INTEGRATION_CHANNELS} from '../configs/constants';
4
+ import type {DatasourceConstructorRegistry} from '../middlewares/datasource.middleware';
5
+ import {
6
+ type IntegrationMessageFailedPayload,
7
+ type IntegrationMessageReceivedPayload,
8
+ type IntegrationMessageSentPayload,
9
+ } from '../types/channels';
4
10
  import type {AppContext} from '../types/context';
5
11
  import type {IntegrationConfig} from '../types/integration';
6
12
  import type {
7
13
  NormalizedMessage,
8
- PlatformMessage,
9
14
  SendResult,
10
15
  WebhookEvent,
11
16
  } from '../types/message';
17
+ import type {DatasourceInstanceTypes} from '../types/datasource';
12
18
  import type {
13
19
  IntegrationServiceConfig,
14
20
  IntegrationServiceSchema,
15
21
  } from '../types/service';
22
+ import {defineService} from './define-service';
16
23
 
17
24
  /**
18
25
  * Security helpers for webhook validation
@@ -38,13 +45,6 @@ const SecurityHelpers = {
38
45
  validateTimestamp(timestamp: number, maxAgeMs = 5 * 60 * 1000): boolean {
39
46
  return Date.now() - timestamp <= maxAgeMs;
40
47
  },
41
-
42
- /**
43
- * Generate correlation ID for request tracking
44
- */
45
- generateCorrelationId(): string {
46
- return crypto.randomUUID();
47
- },
48
48
  };
49
49
 
50
50
  /**
@@ -76,37 +76,52 @@ async function executeWithRetry<T>(
76
76
  }
77
77
 
78
78
  export function defineIntegration<
79
- TPlatformMessage extends PlatformMessage = PlatformMessage,
80
79
  TSettings = unknown,
81
- TDatasources = unknown
80
+ TDatasourceConstructors extends DatasourceConstructorRegistry = DatasourceConstructorRegistry
82
81
  >(
83
- config: IntegrationServiceConfig<
84
- TPlatformMessage,
85
- TSettings,
86
- AppContext<TDatasources>
87
- >
88
- ): IntegrationServiceSchema<TPlatformMessage, TSettings> {
82
+ config: IntegrationServiceConfig<TSettings, TDatasourceConstructors>
83
+ ): IntegrationServiceSchema<TSettings> {
84
+ // Type alias for inferred datasource instances
85
+ type TDatasources = DatasourceInstanceTypes<TDatasourceConstructors>;
86
+
89
87
  // Create actions from integration methods
90
88
  const actions = {
91
89
  i_receiveWebhook: {
92
90
  rest: {
93
91
  method: 'POST' as const,
94
- path: '/:tenantId',
92
+ path: '/:channelId',
95
93
  },
96
94
  params: {
97
- tenantId: 'string',
95
+ channelId: 'string',
98
96
  payload: 'object',
99
97
  headers: 'object',
100
98
  timestamp: 'number',
101
99
  },
102
100
  async handler(
103
- ctx: AppContext<TDatasources, WebhookEvent>
104
- ): Promise<{success: boolean; messages: number}> {
105
- const webhook = ctx.params;
106
- const correlationId = SecurityHelpers.generateCorrelationId();
101
+ ctx: AppContext<
102
+ TDatasources,
103
+ {
104
+ channelId: string;
105
+ payload: object;
106
+ headers: object;
107
+ timestamp: number;
108
+ }
109
+ >
110
+ ): Promise<{success: boolean; messages: number; failed: number}> {
111
+ const {channelId, payload, headers, timestamp} = ctx.params;
112
+
113
+ // TODO: Look up channel configuration by channelId
114
+ // const channel = await getChannelConfig(channelId);
115
+ // For now, we'll construct webhook from meta
107
116
 
108
- // Add correlation ID to context for tracking
109
- ctx.meta.correlationId = correlationId;
117
+ const webhook: WebhookEvent = {
118
+ tenantId: ctx.meta.tenantId ?? 'unknown', // Should come from channel lookup
119
+ channelId,
120
+ platform: config.spec.platform,
121
+ payload, // Raw webhook payload from gateway
122
+ headers: headers as Record<string, string>,
123
+ timestamp,
124
+ };
110
125
 
111
126
  // Validate timestamp to prevent replay attacks
112
127
  if (!SecurityHelpers.validateTimestamp(webhook.timestamp)) {
@@ -130,25 +145,49 @@ export function defineIntegration<
130
145
  }
131
146
 
132
147
  // Normalize the message
133
- const normalizedMessages: NormalizedMessage[] = await config.normalize(
134
- webhook
148
+ const normalizedMessages = await config.normalize(webhook);
149
+
150
+ // Process each message with reliable delivery (parallel)
151
+ const results = await Promise.allSettled(
152
+ normalizedMessages.map(async (message) => {
153
+ const payload: IntegrationMessageReceivedPayload = {
154
+ tenantId: webhook.tenantId,
155
+ channelId: webhook.channelId,
156
+ platform: webhook.platform,
157
+ message,
158
+ timestamp: Date.now(),
159
+ };
160
+
161
+ // Send to reliable message channel (NATS JetStream)
162
+ return ctx.broker.sendToChannel(
163
+ INTEGRATION_CHANNELS.MESSAGE_RECEIVED,
164
+ payload,
165
+ CHANNEL_CONFIG.HIGH_PRIORITY
166
+ );
167
+ })
135
168
  );
136
169
 
137
- // Process each message
138
- for (const message of normalizedMessages) {
139
- // Emit event for further processing with correlation ID
140
- ctx.emit('integration.message.received', {
141
- correlationId,
142
- tenantId: webhook.tenantId,
143
- channelId: webhook.channelId,
144
- integrationId: webhook.integrationId,
145
- platform: webhook.platform,
146
- message,
147
- timestamp: Date.now(),
148
- });
170
+ // Count successful vs failed messages
171
+ const successful = results.filter(
172
+ (result) => result.status === 'fulfilled'
173
+ ).length;
174
+ const failed = results.filter(
175
+ (result) => result.status === 'rejected'
176
+ ).length;
177
+
178
+ // Log failures for monitoring (but don't fail the entire webhook)
179
+ if (failed > 0) {
180
+ const failures = results
181
+ .filter((result) => result.status === 'rejected')
182
+ .map((result) => (result as PromiseRejectedResult).reason);
183
+
184
+ ctx.broker.logger.warn(
185
+ `Webhook processing partial failure: ${failed}/${normalizedMessages.length} messages failed`,
186
+ {failures}
187
+ );
149
188
  }
150
189
 
151
- return {success: true, messages: normalizedMessages.length};
190
+ return {success: true, messages: successful, failed};
152
191
  },
153
192
  },
154
193
 
@@ -159,20 +198,26 @@ export function defineIntegration<
159
198
  },
160
199
  params: {
161
200
  message: 'object',
162
- config: 'object',
201
+ channelId: 'string',
163
202
  },
164
- async handler(ctx: AppContext<TDatasources>): Promise<unknown> {
165
- const {message, config: integrationConfig} = ctx.params as {
166
- message: NormalizedMessage;
167
- config: IntegrationConfig;
168
- };
203
+ async handler(
204
+ ctx: AppContext<
205
+ TDatasources,
206
+ {
207
+ message: NormalizedMessage;
208
+ channelId: string;
209
+ }
210
+ >
211
+ ): Promise<SendResult> {
212
+ const {message, channelId} = ctx.params;
169
213
 
170
- // Transform message if method exists
171
- if (config.transform) {
172
- await config.transform(message, integrationConfig);
214
+ // Load channel configuration
215
+ const integrationConfig = await config.getChannelConfig(ctx, channelId);
216
+ if (!integrationConfig) {
217
+ throw new Error(`Channel configuration not found: ${channelId}`);
173
218
  }
174
219
 
175
- // Send the message with retry mechanism
220
+ // Send the message with retry mechanism (transformation happens inside sendMessage)
176
221
  const result: SendResult = await executeWithRetry(
177
222
  () => config.sendMessage(ctx, message, integrationConfig),
178
223
  3,
@@ -180,18 +225,49 @@ export function defineIntegration<
180
225
  `Send message via ${config.spec.platform}`
181
226
  );
182
227
 
183
- // Emit event based on result
184
- if (result.success) {
185
- ctx.emit('integration.message.sent', {
228
+ // Send reliable events based on result (with error handling)
229
+ try {
230
+ if (result.success) {
231
+ const sentPayload: IntegrationMessageSentPayload = {
232
+ tenantId: integrationConfig.tenantId,
233
+ channelId,
234
+ platform: config.spec.platform,
235
+ messageId: result.messageId,
236
+ metadata: result.metadata,
237
+ timestamp: Date.now(),
238
+ };
239
+
240
+ await ctx.broker.sendToChannel(
241
+ INTEGRATION_CHANNELS.MESSAGE_SENT,
242
+ sentPayload,
243
+ CHANNEL_CONFIG.DEFAULTS
244
+ );
245
+ } else {
246
+ const failedPayload: IntegrationMessageFailedPayload = {
247
+ tenantId: integrationConfig.tenantId,
248
+ channelId,
249
+ platform: config.spec.platform,
250
+ error: result.error?.message ?? 'Unknown error',
251
+ message,
252
+ timestamp: Date.now(),
253
+ };
254
+
255
+ await ctx.broker.sendToChannel(
256
+ INTEGRATION_CHANNELS.MESSAGE_FAILED,
257
+ failedPayload,
258
+ CHANNEL_CONFIG.DEFAULTS
259
+ );
260
+ }
261
+ } catch (channelError: unknown) {
262
+ // Log channel error but don't fail the send operation
263
+ const err =
264
+ channelError instanceof Error
265
+ ? channelError
266
+ : new Error(String(channelError));
267
+ ctx.broker.logger.warn('Failed to send message event to channel', {
268
+ error: err.message,
186
269
  messageId: result.messageId,
187
270
  platform: config.spec.platform,
188
- metadata: result.metadata,
189
- });
190
- } else {
191
- ctx.emit('integration.message.failed', {
192
- error: result.error,
193
- platform: config.spec.platform,
194
- message,
195
271
  });
196
272
  }
197
273
 
@@ -267,23 +343,6 @@ export function defineIntegration<
267
343
  },
268
344
  },
269
345
 
270
- i_status: {
271
- rest: {
272
- method: 'GET' as const,
273
- path: '/status',
274
- },
275
- handler(): object {
276
- return {
277
- id: config.spec.id,
278
- name: config.spec.name,
279
- platform: config.spec.platform,
280
- version: config.spec.version,
281
- status: config.spec.status,
282
- capabilities: config.spec.capabilities,
283
- };
284
- },
285
- },
286
-
287
346
  i_validateCredentials: {
288
347
  params: {
289
348
  credentials: 'object',
@@ -305,7 +364,7 @@ export function defineIntegration<
305
364
  }
306
365
 
307
366
  // Use defineService to get all the mixins and proper setup
308
- const baseService = defineService<TDatasources, TSettings>({
367
+ const baseService = defineService<TSettings, TDatasourceConstructors>({
309
368
  name: config.name,
310
369
  version: config.version,
311
370
  settings: config.settings as TSettings,
@@ -320,12 +379,12 @@ export function defineIntegration<
320
379
  methods: {
321
380
  ...(config.methods ?? {}),
322
381
  // Add integration-specific methods to the service methods (filter out undefined)
323
- // normalize is required, no need for conditional
382
+ // Required methods - no need for conditional
324
383
  normalize: config.normalize,
325
- ...(config.transform && {transform: config.transform}),
326
- ...(config.validateWebhook && {validateWebhook: config.validateWebhook}),
327
- // sendMessage is required, no need for conditional
384
+ getChannelConfig: config.getChannelConfig,
328
385
  sendMessage: config.sendMessage,
386
+ // Optional methods
387
+ ...(config.validateWebhook && {validateWebhook: config.validateWebhook}),
329
388
  ...(config.verifyWebhook && {verifyWebhook: config.verifyWebhook}),
330
389
  ...(config.checkHealth && {checkHealth: config.checkHealth}),
331
390
  ...(config.validateCredentials && {
@@ -345,5 +404,5 @@ export function defineIntegration<
345
404
  ...baseService,
346
405
  // Only add the integration spec
347
406
  spec: config.spec,
348
- } as IntegrationServiceSchema<TPlatformMessage, TSettings>;
407
+ } as IntegrationServiceSchema<TSettings>;
349
408
  }