@recombine-ai/platform 0.2.5 → 0.2.6-test.10
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/build/analytics.d.ts +42 -0
- package/build/analytics.d.ts.map +1 -0
- package/build/analytics.js +129 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -0
- package/build/interfaces/platform.d.ts +28 -7
- package/build/interfaces/platform.d.ts.map +1 -1
- package/build/interfaces/platform.js +17 -2
- package/build/postgres-analytics-query.d.ts +87 -0
- package/build/postgres-analytics-query.d.ts.map +1 -0
- package/build/postgres-analytics-query.js +392 -0
- package/build/postgres-analytics.d.ts +45 -0
- package/build/postgres-analytics.d.ts.map +1 -0
- package/build/postgres-analytics.js +295 -0
- package/package.json +3 -1
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export type AnalyticsScalar = boolean | number | string;
|
|
3
|
+
export type AnalyticsFieldType = 'boolean' | 'number' | 'string';
|
|
4
|
+
declare const analyticsFieldDefinitionBrand: unique symbol;
|
|
5
|
+
export interface AnalyticsFieldDefinition<T> {
|
|
6
|
+
readonly type: AnalyticsFieldType;
|
|
7
|
+
readonly schema: z.ZodType<T>;
|
|
8
|
+
readonly [analyticsFieldDefinitionBrand]: true;
|
|
9
|
+
}
|
|
10
|
+
export type AnalyticsFieldDefinitions = Readonly<Record<string, AnalyticsFieldDefinition<unknown>>>;
|
|
11
|
+
export type AnalyticsValue<TField extends AnalyticsFieldDefinition<unknown>> = TField extends AnalyticsFieldDefinition<infer TValue> ? TValue : never;
|
|
12
|
+
type AnalyticsFieldName<TFields extends AnalyticsFieldDefinitions> = Extract<keyof TFields, string>;
|
|
13
|
+
export interface ConversationAnalyticsSummary {
|
|
14
|
+
conversationId: string;
|
|
15
|
+
agentId: string;
|
|
16
|
+
channel: string;
|
|
17
|
+
startedAt: Date;
|
|
18
|
+
}
|
|
19
|
+
export interface AnalyticsStore {
|
|
20
|
+
set(conversationId: string, agentId: string, fieldName: string, value: AnalyticsScalar): Promise<void>;
|
|
21
|
+
clear(conversationId: string, agentId: string, fieldName: string): Promise<void>;
|
|
22
|
+
get(conversationId: string, agentId: string, fieldName: string): Promise<unknown | undefined>;
|
|
23
|
+
getSummary(conversationId: string, agentId: string): Promise<ConversationAnalyticsSummary | undefined>;
|
|
24
|
+
}
|
|
25
|
+
export interface AgentAnalytics<TFields extends AnalyticsFieldDefinitions> {
|
|
26
|
+
set<TName extends AnalyticsFieldName<TFields>>(conversationId: string, fieldName: TName, value: AnalyticsValue<TFields[TName]>): Promise<void>;
|
|
27
|
+
clear<TName extends AnalyticsFieldName<TFields>>(conversationId: string, fieldName: TName): Promise<void>;
|
|
28
|
+
get<TName extends AnalyticsFieldName<TFields>>(conversationId: string, fieldName: TName): Promise<AnalyticsValue<TFields[TName]> | undefined>;
|
|
29
|
+
getSummary(conversationId: string): Promise<ConversationAnalyticsSummary | undefined>;
|
|
30
|
+
}
|
|
31
|
+
export declare const analyticsField: {
|
|
32
|
+
boolean(): AnalyticsFieldDefinition<boolean>;
|
|
33
|
+
number(): AnalyticsFieldDefinition<number>;
|
|
34
|
+
string(): AnalyticsFieldDefinition<string>;
|
|
35
|
+
enum<const TValues extends readonly [string, ...string[]]>(values: TValues): AnalyticsFieldDefinition<TValues[number]>;
|
|
36
|
+
};
|
|
37
|
+
export declare function validateAgentAnalytics(agentId: string, fields: AnalyticsFieldDefinitions): void;
|
|
38
|
+
export declare function createAgentAnalytics<TFields extends AnalyticsFieldDefinitions>(store: AnalyticsStore, agentId: string, fields: TFields): AgentAnalytics<TFields>;
|
|
39
|
+
export declare function createInMemoryAnalytics(summaries?: ConversationAnalyticsSummary[]): AnalyticsStore;
|
|
40
|
+
export declare function assertAnalyticsScalar(value: unknown): asserts value is AnalyticsScalar;
|
|
41
|
+
export {};
|
|
42
|
+
//# sourceMappingURL=analytics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"analytics.d.ts","sourceRoot":"","sources":["../src/analytics.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.analyticsField = void 0;
|
|
4
|
+
exports.validateAgentAnalytics = validateAgentAnalytics;
|
|
5
|
+
exports.createAgentAnalytics = createAgentAnalytics;
|
|
6
|
+
exports.createInMemoryAnalytics = createInMemoryAnalytics;
|
|
7
|
+
exports.assertAnalyticsScalar = assertAnalyticsScalar;
|
|
8
|
+
const zod_1 = require("zod");
|
|
9
|
+
const analyticsFieldDefinitionBrand = Symbol('AnalyticsFieldDefinition');
|
|
10
|
+
const analyticsFieldDefinitions = new WeakSet();
|
|
11
|
+
function defineField(type, schema) {
|
|
12
|
+
const definition = {
|
|
13
|
+
type,
|
|
14
|
+
schema,
|
|
15
|
+
[analyticsFieldDefinitionBrand]: true,
|
|
16
|
+
};
|
|
17
|
+
analyticsFieldDefinitions.add(definition);
|
|
18
|
+
return Object.freeze(definition);
|
|
19
|
+
}
|
|
20
|
+
exports.analyticsField = {
|
|
21
|
+
boolean() {
|
|
22
|
+
return defineField('boolean', zod_1.z.boolean());
|
|
23
|
+
},
|
|
24
|
+
number() {
|
|
25
|
+
return defineField('number', zod_1.z.number().finite());
|
|
26
|
+
},
|
|
27
|
+
string() {
|
|
28
|
+
return defineField('string', zod_1.z.string());
|
|
29
|
+
},
|
|
30
|
+
enum(values) {
|
|
31
|
+
return defineField('string', zod_1.z.enum(values));
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
function validateAgentAnalytics(agentId, fields) {
|
|
35
|
+
if (agentId.trim() === '') {
|
|
36
|
+
throw new Error('Analytics agent ID must not be empty');
|
|
37
|
+
}
|
|
38
|
+
for (const [fieldName, value] of Object.entries(fields)) {
|
|
39
|
+
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(fieldName)) {
|
|
40
|
+
throw new Error('Analytics field name contains unsupported characters');
|
|
41
|
+
}
|
|
42
|
+
if (!isAnalyticsFieldDefinition(value)) {
|
|
43
|
+
throw new Error(`Analytics field '${fieldName}' must be declared with analyticsField`);
|
|
44
|
+
}
|
|
45
|
+
const definition = value;
|
|
46
|
+
if (!['boolean', 'number', 'string'].includes(definition.type)) {
|
|
47
|
+
throw new Error(`Analytics field '${fieldName}' has an unsupported type`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function isAnalyticsFieldDefinition(value) {
|
|
52
|
+
return typeof value === 'object' && value !== null && analyticsFieldDefinitions.has(value);
|
|
53
|
+
}
|
|
54
|
+
function createAgentAnalytics(store, agentId, fields) {
|
|
55
|
+
validateAgentAnalytics(agentId, fields);
|
|
56
|
+
const getDefinition = (fieldName) => {
|
|
57
|
+
const definition = fields[fieldName];
|
|
58
|
+
if (!definition) {
|
|
59
|
+
throw new Error(`Analytics field '${fieldName}' is not declared for agent '${agentId}'`);
|
|
60
|
+
}
|
|
61
|
+
return definition;
|
|
62
|
+
};
|
|
63
|
+
const parseValue = (fieldName, value) => getDefinition(fieldName).schema.parse(value);
|
|
64
|
+
return {
|
|
65
|
+
async set(conversationId, fieldName, value) {
|
|
66
|
+
const parsed = parseValue(fieldName, value);
|
|
67
|
+
assertAnalyticsScalar(parsed);
|
|
68
|
+
await store.set(conversationId, agentId, fieldName, parsed);
|
|
69
|
+
},
|
|
70
|
+
async clear(conversationId, fieldName) {
|
|
71
|
+
getDefinition(fieldName);
|
|
72
|
+
await store.clear(conversationId, agentId, fieldName);
|
|
73
|
+
},
|
|
74
|
+
async get(conversationId, fieldName) {
|
|
75
|
+
getDefinition(fieldName);
|
|
76
|
+
const value = await store.get(conversationId, agentId, fieldName);
|
|
77
|
+
return value === undefined ? undefined : parseValue(fieldName, value);
|
|
78
|
+
},
|
|
79
|
+
getSummary(conversationId) {
|
|
80
|
+
return store.getSummary(conversationId, agentId);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function createInMemoryAnalytics(summaries = []) {
|
|
85
|
+
const conversations = new Map();
|
|
86
|
+
const conversationAgents = new Map(summaries.map((summary) => [summary.conversationId, summary.agentId]));
|
|
87
|
+
const summariesById = new Map(summaries.map((summary) => [summary.conversationId, structuredClone(summary)]));
|
|
88
|
+
return {
|
|
89
|
+
async set(conversationId, agentId, fieldName, value) {
|
|
90
|
+
const existingAgentId = conversationAgents.get(conversationId);
|
|
91
|
+
if (existingAgentId !== undefined && existingAgentId !== agentId) {
|
|
92
|
+
throw new Error(`Conversation '${conversationId}' belongs to another agent`);
|
|
93
|
+
}
|
|
94
|
+
assertAnalyticsScalar(value);
|
|
95
|
+
const values = conversations.get(conversationId) ?? new Map();
|
|
96
|
+
values.set(fieldName, value);
|
|
97
|
+
conversations.set(conversationId, values);
|
|
98
|
+
conversationAgents.set(conversationId, agentId);
|
|
99
|
+
},
|
|
100
|
+
async clear(conversationId, agentId, fieldName) {
|
|
101
|
+
if (conversationAgents.get(conversationId) !== agentId) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const values = conversations.get(conversationId);
|
|
105
|
+
values?.delete(fieldName);
|
|
106
|
+
if (values?.size === 0) {
|
|
107
|
+
conversations.delete(conversationId);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
async get(conversationId, agentId, fieldName) {
|
|
111
|
+
if (conversationAgents.get(conversationId) !== agentId) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
return conversations.get(conversationId)?.get(fieldName);
|
|
115
|
+
},
|
|
116
|
+
async getSummary(conversationId, agentId) {
|
|
117
|
+
const summary = summariesById.get(conversationId);
|
|
118
|
+
return summary?.agentId === agentId ? structuredClone(summary) : undefined;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function assertAnalyticsScalar(value) {
|
|
123
|
+
if (typeof value === 'boolean' ||
|
|
124
|
+
typeof value === 'string' ||
|
|
125
|
+
(typeof value === 'number' && Number.isFinite(value))) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
throw new Error('Analytics value must be a boolean, finite number, or string');
|
|
129
|
+
}
|
package/build/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export * from './interfaces/platform';
|
|
2
2
|
export * from './interfaces/rc-voice';
|
|
3
3
|
export * from './context-cache';
|
|
4
|
+
export * from './analytics';
|
|
5
|
+
export * from './postgres-analytics';
|
|
6
|
+
export * from './postgres-analytics-query';
|
|
4
7
|
export * from './timeouts-manager';
|
|
5
8
|
//# sourceMappingURL=index.d.ts.map
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAA;AACrC,cAAc,uBAAuB,CAAA;AACrC,cAAc,iBAAiB,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAA;AACrC,cAAc,uBAAuB,CAAA;AACrC,cAAc,iBAAiB,CAAA;AAG/B,cAAc,aAAa,CAAA;AAC3B,cAAc,sBAAsB,CAAA;AACpC,cAAc,4BAA4B,CAAA;AAC1C,cAAc,oBAAoB,CAAA"}
|
package/build/index.js
CHANGED
|
@@ -17,4 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
17
17
|
__exportStar(require("./interfaces/platform"), exports);
|
|
18
18
|
__exportStar(require("./interfaces/rc-voice"), exports);
|
|
19
19
|
__exportStar(require("./context-cache"), exports);
|
|
20
|
+
__exportStar(require("./analytics"), exports);
|
|
21
|
+
__exportStar(require("./postgres-analytics"), exports);
|
|
22
|
+
__exportStar(require("./postgres-analytics-query"), exports);
|
|
20
23
|
__exportStar(require("./timeouts-manager"), exports);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ReadableStream } from 'node:stream/web';
|
|
2
2
|
import { AIEngine, AIStreamEngine, LlmAdapter, LlmStreamAdapter, Logger, Message, PromptFS, ResponseChunk } from '@recombine-ai/engine';
|
|
3
|
+
import { AgentAnalytics, AnalyticsFieldDefinitions, AnalyticsStore } from '../analytics';
|
|
3
4
|
import { ContextCache } from '../context-cache';
|
|
4
5
|
import { TimeoutManager } from '../timeouts-manager';
|
|
5
6
|
import { IRcVoice } from './rc-voice';
|
|
@@ -7,33 +8,53 @@ export interface IModels {
|
|
|
7
8
|
models: Record<string, LlmAdapter>;
|
|
8
9
|
streamingModels: Record<string, LlmStreamAdapter>;
|
|
9
10
|
}
|
|
10
|
-
export interface Platform<TContext extends {}, TModels extends IModels
|
|
11
|
+
export interface Platform<TContext extends {}, TModels extends IModels, TRcVoice = IRcVoice, TTimeoutManager = TimeoutManager, TContextCache = ContextCache<TContext>> {
|
|
11
12
|
streamAi: AIStreamEngine<TContext>;
|
|
12
13
|
ai: AIEngine<TContext>;
|
|
13
14
|
fs: PromptFS;
|
|
14
|
-
rcVoice:
|
|
15
|
+
rcVoice: TRcVoice;
|
|
15
16
|
logger: Logger;
|
|
16
|
-
timeoutManager:
|
|
17
|
+
timeoutManager: TTimeoutManager;
|
|
17
18
|
models: TModels['models'];
|
|
18
19
|
streamingModels: TModels['streamingModels'];
|
|
19
|
-
contextCache:
|
|
20
|
+
contextCache: TContextCache;
|
|
21
|
+
analytics: AnalyticsStore;
|
|
20
22
|
}
|
|
21
23
|
export interface CallInfo {
|
|
22
24
|
callId: string;
|
|
23
25
|
phoneNumber?: string;
|
|
24
26
|
headers: Record<string, unknown>;
|
|
25
27
|
}
|
|
28
|
+
export type VoiceSettings = Record<string, string | number | boolean | number[]>;
|
|
26
29
|
export interface VoiceAgent {
|
|
27
30
|
onCallStart: (call: CallInfo) => Promise<{
|
|
28
31
|
initialMessage: string;
|
|
29
|
-
initialSettings?:
|
|
32
|
+
initialSettings?: VoiceSettings;
|
|
30
33
|
}>;
|
|
31
34
|
onCallReady: (call: CallInfo) => Promise<void>;
|
|
32
35
|
streamResponse: (messages: Message[], call: CallInfo) => Promise<ReadableStream<ResponseChunk>>;
|
|
33
|
-
onCallEnd: (call: CallInfo) => Promise<void>;
|
|
36
|
+
onCallEnd: (call: CallInfo, completedConversation?: Message[]) => Promise<void>;
|
|
34
37
|
onAgentSpeechDone: (messages: Message[], call: CallInfo) => Promise<void>;
|
|
35
38
|
onServiceStartup: () => Promise<void>;
|
|
36
39
|
onFatalError: (callId: string, err: any) => Promise<void>;
|
|
37
40
|
}
|
|
38
|
-
export
|
|
41
|
+
export type AgentPlatform<TPlatform extends {
|
|
42
|
+
analytics: AnalyticsStore;
|
|
43
|
+
}, TFields extends AnalyticsFieldDefinitions> = Omit<TPlatform, 'analytics'> & {
|
|
44
|
+
analytics: AgentAnalytics<TFields>;
|
|
45
|
+
};
|
|
46
|
+
type ComposedPlatform<TContext extends object, TModels extends IModels> = Platform<TContext, TModels, unknown, unknown, unknown>;
|
|
47
|
+
export interface AgentDefinition<TFields extends AnalyticsFieldDefinitions> {
|
|
48
|
+
readonly agentId: string;
|
|
49
|
+
readonly analytics: Readonly<TFields>;
|
|
50
|
+
}
|
|
51
|
+
export interface AgentDeclaration<TContext extends object, TServices, TModels extends IModels, TFields extends AnalyticsFieldDefinitions, TPlatform extends ComposedPlatform<TContext, TModels> = Platform<TContext, TModels>> extends AgentDefinition<TFields> {
|
|
52
|
+
create: (platform: AgentPlatform<TPlatform, TFields>, services: TServices) => VoiceAgent;
|
|
53
|
+
}
|
|
54
|
+
export interface AgentCreator<TContext extends object, TServices, TModels extends IModels, TFields extends AnalyticsFieldDefinitions, TPlatform extends ComposedPlatform<TContext, TModels> = Platform<TContext, TModels>> {
|
|
55
|
+
(platform: TPlatform, services: TServices): VoiceAgent;
|
|
56
|
+
readonly definition: AgentDefinition<TFields>;
|
|
57
|
+
}
|
|
58
|
+
export declare function createAgent<TContext extends object, TServices, TModels extends IModels, const TFields extends AnalyticsFieldDefinitions, TPlatform extends ComposedPlatform<TContext, TModels> = Platform<TContext, TModels>>(declaration: AgentDeclaration<TContext, TServices, TModels, TFields, TPlatform>): AgentCreator<TContext, TServices, TModels, TFields, TPlatform>;
|
|
59
|
+
export {};
|
|
39
60
|
//# sourceMappingURL=platform.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"platform.d.ts","sourceRoot":"","sources":["../../src/interfaces/platform.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,OAAO,EACH,QAAQ,EACR,cAAc,EACd,UAAU,EACV,gBAAgB,EAChB,MAAM,EACN,OAAO,EACP,QAAQ,EACR,aAAa,EAChB,MAAM,sBAAsB,CAAA;AAE7B,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AACpD,OAAO,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAA;AAErC,MAAM,WAAW,OAAO;IACpB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;IAClC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAA;CACpD;
|
|
1
|
+
{"version":3,"file":"platform.d.ts","sourceRoot":"","sources":["../../src/interfaces/platform.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createAgent = createAgent;
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
const analytics_1 = require("../analytics");
|
|
5
|
+
function createAgent(declaration) {
|
|
6
|
+
const { agentId, create } = declaration;
|
|
7
|
+
const analytics = Object.freeze({ ...declaration.analytics });
|
|
8
|
+
(0, analytics_1.validateAgentAnalytics)(agentId, analytics);
|
|
9
|
+
const creator = (platform, services) => {
|
|
10
|
+
const agentPlatform = {
|
|
11
|
+
...platform,
|
|
12
|
+
analytics: (0, analytics_1.createAgentAnalytics)(platform.analytics, agentId, analytics),
|
|
13
|
+
};
|
|
14
|
+
return create(agentPlatform, services);
|
|
15
|
+
};
|
|
16
|
+
const definition = Object.freeze({ agentId, analytics });
|
|
17
|
+
return Object.defineProperty(creator, 'definition', {
|
|
18
|
+
enumerable: true,
|
|
19
|
+
value: definition,
|
|
20
|
+
});
|
|
6
21
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { PoolConfig, QueryResult } from 'pg';
|
|
2
|
+
import { AnalyticsScalar } from './analytics';
|
|
3
|
+
export type AnalyticsFilterScalar = boolean | number | string;
|
|
4
|
+
export type AnalyticsFilterValueType = 'boolean' | 'number' | 'string';
|
|
5
|
+
export type AnalyticsFilterOperator = 'eq' | 'exists' | 'gt' | 'gte' | 'in' | 'lt' | 'lte';
|
|
6
|
+
export interface AnalyticsFilter {
|
|
7
|
+
key: string;
|
|
8
|
+
type: AnalyticsFilterValueType;
|
|
9
|
+
operator: AnalyticsFilterOperator;
|
|
10
|
+
value?: AnalyticsFilterScalar;
|
|
11
|
+
values?: AnalyticsFilterScalar[];
|
|
12
|
+
}
|
|
13
|
+
export type AnalyticsMetric = {
|
|
14
|
+
operation: 'count';
|
|
15
|
+
} | {
|
|
16
|
+
operation: 'rate';
|
|
17
|
+
filter: AnalyticsFilter;
|
|
18
|
+
} | {
|
|
19
|
+
operation: 'average' | 'sum';
|
|
20
|
+
key: string;
|
|
21
|
+
};
|
|
22
|
+
export type AnalyticsTimeGranularity = 'hour' | 'day' | 'week' | 'month';
|
|
23
|
+
export type AnalyticsQueryParameters = Readonly<Record<string, unknown>>;
|
|
24
|
+
export interface TelescopeConversation {
|
|
25
|
+
conversationId: string;
|
|
26
|
+
medium: string;
|
|
27
|
+
agentId: string;
|
|
28
|
+
region?: string;
|
|
29
|
+
phoneNumber?: string;
|
|
30
|
+
duration?: number;
|
|
31
|
+
audioUrl?: string;
|
|
32
|
+
createdAt: number;
|
|
33
|
+
endedAt?: number;
|
|
34
|
+
analytics: Record<string, AnalyticsScalar>;
|
|
35
|
+
}
|
|
36
|
+
export interface TelescopeConversationPage {
|
|
37
|
+
limit: number;
|
|
38
|
+
items: TelescopeConversation[];
|
|
39
|
+
lastConversationId?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface AnalyticsFieldValue {
|
|
42
|
+
value: AnalyticsFilterScalar;
|
|
43
|
+
count: number;
|
|
44
|
+
}
|
|
45
|
+
export interface AnalyticsFieldMetadata {
|
|
46
|
+
key: string;
|
|
47
|
+
type: AnalyticsFilterValueType;
|
|
48
|
+
variantCount: number;
|
|
49
|
+
values: AnalyticsFieldValue[];
|
|
50
|
+
}
|
|
51
|
+
export interface AnalyticsFields {
|
|
52
|
+
items: AnalyticsFieldMetadata[];
|
|
53
|
+
}
|
|
54
|
+
export interface AnalyticsTimeSeriesItem {
|
|
55
|
+
startAt: string;
|
|
56
|
+
value: number | null;
|
|
57
|
+
numerator?: number;
|
|
58
|
+
denominator?: number;
|
|
59
|
+
}
|
|
60
|
+
export interface AnalyticsTimeSeries {
|
|
61
|
+
metric: AnalyticsMetric;
|
|
62
|
+
granularity: AnalyticsTimeGranularity;
|
|
63
|
+
from: string;
|
|
64
|
+
to: string;
|
|
65
|
+
items: AnalyticsTimeSeriesItem[];
|
|
66
|
+
}
|
|
67
|
+
interface Queryable {
|
|
68
|
+
query<TResult extends Record<string, unknown> = Record<string, unknown>>(text: string, values?: unknown[]): Promise<QueryResult<TResult>>;
|
|
69
|
+
}
|
|
70
|
+
export declare class AnalyticsQueryError extends Error {
|
|
71
|
+
name: string;
|
|
72
|
+
}
|
|
73
|
+
export declare class PostgresAnalyticsQueryService {
|
|
74
|
+
private readonly database;
|
|
75
|
+
constructor(database: Queryable);
|
|
76
|
+
getConversation(conversationId: string): Promise<TelescopeConversation | undefined>;
|
|
77
|
+
getConversations(query: AnalyticsQueryParameters): Promise<TelescopeConversationPage>;
|
|
78
|
+
getAnalyticsFields(query: AnalyticsQueryParameters): Promise<AnalyticsFields>;
|
|
79
|
+
getTimeSeries(query: AnalyticsQueryParameters): Promise<AnalyticsTimeSeries>;
|
|
80
|
+
}
|
|
81
|
+
export interface PostgresAnalyticsQueryServices {
|
|
82
|
+
queries: PostgresAnalyticsQueryService;
|
|
83
|
+
close(): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
export declare function createPostgresAnalyticsQueryServices(config: PoolConfig): PostgresAnalyticsQueryServices;
|
|
86
|
+
export {};
|
|
87
|
+
//# sourceMappingURL=postgres-analytics-query.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"postgres-analytics-query.d.ts","sourceRoot":"","sources":["../src/postgres-analytics-query.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PostgresAnalyticsQueryService = exports.AnalyticsQueryError = void 0;
|
|
4
|
+
exports.createPostgresAnalyticsQueryServices = createPostgresAnalyticsQueryServices;
|
|
5
|
+
const pg_1 = require("pg");
|
|
6
|
+
class AnalyticsQueryError extends Error {
|
|
7
|
+
name = 'AnalyticsQueryError';
|
|
8
|
+
}
|
|
9
|
+
exports.AnalyticsQueryError = AnalyticsQueryError;
|
|
10
|
+
class PostgresAnalyticsQueryService {
|
|
11
|
+
database;
|
|
12
|
+
constructor(database) {
|
|
13
|
+
this.database = database;
|
|
14
|
+
}
|
|
15
|
+
async getConversation(conversationId) {
|
|
16
|
+
const result = await this.database.query(`SELECT c.conversation_id, c.agent_id, c.channel, c.region, c.started_at,
|
|
17
|
+
c.ended_at, c.analytics,
|
|
18
|
+
p.phone_number, p.recording_url, p.duration_seconds
|
|
19
|
+
FROM conversations c
|
|
20
|
+
LEFT JOIN phone_calls p ON p.conversation_id = c.conversation_id
|
|
21
|
+
WHERE c.conversation_id = $1`, [conversationId]);
|
|
22
|
+
const row = result.rows[0];
|
|
23
|
+
return row?.agent_id ? conversationRowToJson(row) : undefined;
|
|
24
|
+
}
|
|
25
|
+
async getConversations(query) {
|
|
26
|
+
const parsedLimit = Number.parseInt(optionalString(query.limit, 'limit') ?? '10', 10);
|
|
27
|
+
const requestedLimit = Number.isFinite(parsedLimit)
|
|
28
|
+
? Math.min(100, Math.max(1, parsedLimit))
|
|
29
|
+
: 10;
|
|
30
|
+
const cursor = parseConversationCursor(optionalString(query.lastConversationId, 'lastConversationId'));
|
|
31
|
+
const { bind, conditions, values } = buildConversationConditions(query);
|
|
32
|
+
if (cursor) {
|
|
33
|
+
conditions.push(`(c.started_at, c.conversation_id) < (${bind(cursor.startedAt)}, ${bind(cursor.conversationId)})`);
|
|
34
|
+
}
|
|
35
|
+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
36
|
+
const limitParameter = bind(requestedLimit + 1);
|
|
37
|
+
const result = await this.database.query(`SELECT c.conversation_id, c.agent_id, c.channel, c.region, c.started_at,
|
|
38
|
+
c.ended_at, c.analytics,
|
|
39
|
+
p.phone_number, p.recording_url, p.duration_seconds
|
|
40
|
+
FROM conversations c
|
|
41
|
+
LEFT JOIN phone_calls p ON p.conversation_id = c.conversation_id
|
|
42
|
+
${where}
|
|
43
|
+
ORDER BY c.started_at DESC, c.conversation_id DESC
|
|
44
|
+
LIMIT ${limitParameter}`, values);
|
|
45
|
+
const hasNextPage = result.rows.length > requestedLimit;
|
|
46
|
+
const rows = result.rows.slice(0, requestedLimit);
|
|
47
|
+
return {
|
|
48
|
+
limit: requestedLimit,
|
|
49
|
+
items: rows.map(conversationRowToJson),
|
|
50
|
+
lastConversationId: hasNextPage && rows.length > 0 ? makeConversationCursor(rows.at(-1)) : undefined,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async getAnalyticsFields(query) {
|
|
54
|
+
const agentId = requiredString(query.agentId, 'agentId');
|
|
55
|
+
const values = [agentId];
|
|
56
|
+
const where = "WHERE jsonb_typeof(entry.value) IN ('boolean', 'number', 'string')" +
|
|
57
|
+
' AND c.agent_id = $1';
|
|
58
|
+
const result = await this.database.query(`WITH value_counts AS (
|
|
59
|
+
SELECT entry.key,
|
|
60
|
+
jsonb_typeof(entry.value) AS type,
|
|
61
|
+
entry.value AS value,
|
|
62
|
+
COUNT(*)::bigint AS occurrence_count
|
|
63
|
+
FROM conversations c
|
|
64
|
+
CROSS JOIN LATERAL jsonb_each(c.analytics) AS entry(key, value)
|
|
65
|
+
${where}
|
|
66
|
+
GROUP BY entry.key, type, entry.value
|
|
67
|
+
),
|
|
68
|
+
ranked AS (
|
|
69
|
+
SELECT key,
|
|
70
|
+
type,
|
|
71
|
+
value,
|
|
72
|
+
occurrence_count,
|
|
73
|
+
COUNT(*) OVER (PARTITION BY key, type)::integer AS variant_count,
|
|
74
|
+
ROW_NUMBER() OVER (
|
|
75
|
+
PARTITION BY key, type
|
|
76
|
+
ORDER BY occurrence_count DESC, value::text ASC
|
|
77
|
+
) AS rank
|
|
78
|
+
FROM value_counts
|
|
79
|
+
)
|
|
80
|
+
SELECT key, type, value, occurrence_count, variant_count
|
|
81
|
+
FROM ranked
|
|
82
|
+
WHERE rank <= 50
|
|
83
|
+
ORDER BY key ASC, type ASC, rank ASC`, values);
|
|
84
|
+
return analyticsFieldRowsToJson(result.rows);
|
|
85
|
+
}
|
|
86
|
+
async getTimeSeries(query) {
|
|
87
|
+
const metric = parseMetric(requiredString(query.metric, 'metric'));
|
|
88
|
+
const granularity = (optionalString(query.granularity, 'granularity') ??
|
|
89
|
+
'day');
|
|
90
|
+
if (!['hour', 'day', 'week', 'month'].includes(granularity)) {
|
|
91
|
+
throw new AnalyticsQueryError('granularity must be hour, day, week, or month');
|
|
92
|
+
}
|
|
93
|
+
const { bind, conditions, from, to, values } = buildConversationConditions(query);
|
|
94
|
+
if (!from || !to) {
|
|
95
|
+
throw new AnalyticsQueryError('from and to are required for a time series');
|
|
96
|
+
}
|
|
97
|
+
const bucketExpression = `date_trunc(${bind(granularity)}::text, c.started_at, 'UTC')`;
|
|
98
|
+
let valueExpression;
|
|
99
|
+
let numeratorExpression = 'NULL::bigint';
|
|
100
|
+
if (metric.operation === 'count') {
|
|
101
|
+
valueExpression = 'COUNT(*)::double precision';
|
|
102
|
+
}
|
|
103
|
+
else if (metric.operation === 'rate') {
|
|
104
|
+
const predicate = makeAnalyticsPredicate(metric.filter, bind);
|
|
105
|
+
numeratorExpression = `COUNT(*) FILTER (WHERE ${predicate})::bigint`;
|
|
106
|
+
valueExpression =
|
|
107
|
+
`COUNT(*) FILTER (WHERE ${predicate})::double precision / ` + 'NULLIF(COUNT(*), 0)';
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const key = bind(metric.key);
|
|
111
|
+
const numericValue = `CASE WHEN jsonb_typeof(c.analytics -> (${key}::text)) = 'number' ` +
|
|
112
|
+
`THEN (c.analytics ->> (${key}::text))::double precision END`;
|
|
113
|
+
valueExpression =
|
|
114
|
+
metric.operation === 'average' ? `AVG(${numericValue})` : `SUM(${numericValue})`;
|
|
115
|
+
}
|
|
116
|
+
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
117
|
+
const result = await this.database.query(`SELECT ${bucketExpression} AS bucket_start,
|
|
118
|
+
${valueExpression} AS metric_value,
|
|
119
|
+
${numeratorExpression} AS numerator,
|
|
120
|
+
COUNT(*)::bigint AS denominator
|
|
121
|
+
FROM conversations c
|
|
122
|
+
LEFT JOIN phone_calls p ON p.conversation_id = c.conversation_id
|
|
123
|
+
${where}
|
|
124
|
+
GROUP BY bucket_start
|
|
125
|
+
ORDER BY bucket_start ASC`, values);
|
|
126
|
+
return {
|
|
127
|
+
metric,
|
|
128
|
+
granularity,
|
|
129
|
+
from: from.toISOString(),
|
|
130
|
+
to: to.toISOString(),
|
|
131
|
+
items: result.rows.map((row) => ({
|
|
132
|
+
startAt: new Date(row.bucket_start).toISOString(),
|
|
133
|
+
value: row.metric_value === null ? null : Number(row.metric_value),
|
|
134
|
+
...(metric.operation === 'rate'
|
|
135
|
+
? {
|
|
136
|
+
numerator: Number(row.numerator),
|
|
137
|
+
denominator: Number(row.denominator),
|
|
138
|
+
}
|
|
139
|
+
: {}),
|
|
140
|
+
})),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
exports.PostgresAnalyticsQueryService = PostgresAnalyticsQueryService;
|
|
145
|
+
function createPostgresAnalyticsQueryServices(config) {
|
|
146
|
+
const pool = new pg_1.Pool(config);
|
|
147
|
+
return {
|
|
148
|
+
queries: new PostgresAnalyticsQueryService(pool),
|
|
149
|
+
close: () => pool.end(),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function optionalString(value, name) {
|
|
153
|
+
if (value === undefined)
|
|
154
|
+
return undefined;
|
|
155
|
+
if (typeof value !== 'string') {
|
|
156
|
+
throw new AnalyticsQueryError(`${name} must be a string`);
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
function requiredString(value, name) {
|
|
161
|
+
const result = optionalString(value, name);
|
|
162
|
+
if (!result) {
|
|
163
|
+
throw new AnalyticsQueryError(`${name} is required`);
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
function parseDateFilter(value, name) {
|
|
168
|
+
const stringValue = optionalString(value, name);
|
|
169
|
+
if (stringValue === undefined)
|
|
170
|
+
return undefined;
|
|
171
|
+
const date = /^\d+$/.test(stringValue) ? new Date(Number(stringValue)) : new Date(stringValue);
|
|
172
|
+
if (Number.isNaN(date.getTime())) {
|
|
173
|
+
throw new AnalyticsQueryError(`${name} must be a valid date`);
|
|
174
|
+
}
|
|
175
|
+
return date;
|
|
176
|
+
}
|
|
177
|
+
function parseDurationFilter(value, name) {
|
|
178
|
+
const stringValue = optionalString(value, name);
|
|
179
|
+
if (stringValue === undefined)
|
|
180
|
+
return undefined;
|
|
181
|
+
const duration = Number(stringValue);
|
|
182
|
+
if (!Number.isFinite(duration) || duration < 0) {
|
|
183
|
+
throw new AnalyticsQueryError(`${name} must be a non-negative number`);
|
|
184
|
+
}
|
|
185
|
+
return duration;
|
|
186
|
+
}
|
|
187
|
+
function parseAnalyticsFilters(value) {
|
|
188
|
+
const stringValue = optionalString(value, 'filters');
|
|
189
|
+
if (!stringValue)
|
|
190
|
+
return [];
|
|
191
|
+
let filters;
|
|
192
|
+
try {
|
|
193
|
+
filters = JSON.parse(stringValue);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
throw new AnalyticsQueryError('filters must be valid JSON');
|
|
197
|
+
}
|
|
198
|
+
if (!Array.isArray(filters) || filters.length > 20) {
|
|
199
|
+
throw new AnalyticsQueryError('filters must be an array with at most 20 entries');
|
|
200
|
+
}
|
|
201
|
+
return filters.map(validateAnalyticsFilter);
|
|
202
|
+
}
|
|
203
|
+
function validateAnalyticsFilter(value) {
|
|
204
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
205
|
+
throw new AnalyticsQueryError('Each filter must be an object');
|
|
206
|
+
}
|
|
207
|
+
const filter = value;
|
|
208
|
+
if (typeof filter.key !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/.test(filter.key)) {
|
|
209
|
+
throw new AnalyticsQueryError('Filter key is invalid');
|
|
210
|
+
}
|
|
211
|
+
if (!['boolean', 'number', 'string'].includes(String(filter.type))) {
|
|
212
|
+
throw new AnalyticsQueryError(`Filter '${filter.key}' has an invalid type`);
|
|
213
|
+
}
|
|
214
|
+
const type = filter.type;
|
|
215
|
+
const allowedOperators = type === 'number'
|
|
216
|
+
? ['eq', 'in', 'gt', 'gte', 'lt', 'lte', 'exists']
|
|
217
|
+
: ['eq', 'in', 'exists'];
|
|
218
|
+
if (!allowedOperators.includes(String(filter.operator))) {
|
|
219
|
+
throw new AnalyticsQueryError(`Filter '${filter.key}' has an invalid operator`);
|
|
220
|
+
}
|
|
221
|
+
const operator = filter.operator;
|
|
222
|
+
if (operator === 'in') {
|
|
223
|
+
if (!Array.isArray(filter.values) ||
|
|
224
|
+
filter.values.length === 0 ||
|
|
225
|
+
filter.values.length > 50 ||
|
|
226
|
+
filter.values.some((item) => typeof item !== type || (type === 'number' && !Number.isFinite(item)))) {
|
|
227
|
+
throw new AnalyticsQueryError(`Filter '${filter.key}' has invalid values`);
|
|
228
|
+
}
|
|
229
|
+
return { key: filter.key, type, operator, values: filter.values };
|
|
230
|
+
}
|
|
231
|
+
if (operator !== 'exists' && typeof filter.value !== type) {
|
|
232
|
+
throw new AnalyticsQueryError(`Filter '${filter.key}' has an invalid value`);
|
|
233
|
+
}
|
|
234
|
+
if (type === 'number' && !Number.isFinite(filter.value)) {
|
|
235
|
+
throw new AnalyticsQueryError(`Filter '${filter.key}' has an invalid value`);
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
key: filter.key,
|
|
239
|
+
type,
|
|
240
|
+
operator,
|
|
241
|
+
...(operator === 'exists' ? {} : { value: filter.value }),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
function parseMetric(value) {
|
|
245
|
+
let metric;
|
|
246
|
+
try {
|
|
247
|
+
metric = JSON.parse(value);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
throw new AnalyticsQueryError('metric must be valid JSON');
|
|
251
|
+
}
|
|
252
|
+
if (!metric || typeof metric !== 'object' || Array.isArray(metric)) {
|
|
253
|
+
throw new AnalyticsQueryError('metric must be an object');
|
|
254
|
+
}
|
|
255
|
+
const record = metric;
|
|
256
|
+
if (!['count', 'rate', 'average', 'sum'].includes(String(record.operation))) {
|
|
257
|
+
throw new AnalyticsQueryError('metric operation is invalid');
|
|
258
|
+
}
|
|
259
|
+
if (record.operation === 'rate') {
|
|
260
|
+
return { operation: 'rate', filter: validateAnalyticsFilter(record.filter) };
|
|
261
|
+
}
|
|
262
|
+
if (record.operation === 'average' || record.operation === 'sum') {
|
|
263
|
+
if (typeof record.key !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/.test(record.key)) {
|
|
264
|
+
throw new AnalyticsQueryError('Metric key is invalid');
|
|
265
|
+
}
|
|
266
|
+
return { operation: record.operation, key: record.key };
|
|
267
|
+
}
|
|
268
|
+
return { operation: 'count' };
|
|
269
|
+
}
|
|
270
|
+
function parseConversationCursor(value) {
|
|
271
|
+
if (!value)
|
|
272
|
+
return undefined;
|
|
273
|
+
try {
|
|
274
|
+
const cursor = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
|
|
275
|
+
const startedAt = new Date(String(cursor.startedAt));
|
|
276
|
+
if (Number.isNaN(startedAt.getTime()) ||
|
|
277
|
+
typeof cursor.conversationId !== 'string' ||
|
|
278
|
+
cursor.conversationId === '') {
|
|
279
|
+
throw new Error('invalid cursor');
|
|
280
|
+
}
|
|
281
|
+
return { startedAt, conversationId: cursor.conversationId };
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
throw new AnalyticsQueryError('lastConversationId is invalid');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function makeConversationCursor(row) {
|
|
288
|
+
return Buffer.from(JSON.stringify({
|
|
289
|
+
startedAt: new Date(row.started_at).toISOString(),
|
|
290
|
+
conversationId: row.conversation_id,
|
|
291
|
+
})).toString('base64url');
|
|
292
|
+
}
|
|
293
|
+
function conversationRowToJson(row) {
|
|
294
|
+
return {
|
|
295
|
+
conversationId: row.conversation_id,
|
|
296
|
+
medium: row.channel,
|
|
297
|
+
agentId: row.agent_id,
|
|
298
|
+
region: row.region || undefined,
|
|
299
|
+
phoneNumber: row.phone_number || undefined,
|
|
300
|
+
duration: row.duration_seconds ?? undefined,
|
|
301
|
+
audioUrl: row.recording_url || undefined,
|
|
302
|
+
createdAt: new Date(row.started_at).getTime(),
|
|
303
|
+
endedAt: row.ended_at ? new Date(row.ended_at).getTime() : undefined,
|
|
304
|
+
analytics: analyticsScalars(row.analytics),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function analyticsScalars(values) {
|
|
308
|
+
return Object.fromEntries(Object.entries(values ?? {}).filter((entry) => {
|
|
309
|
+
const value = entry[1];
|
|
310
|
+
return (typeof value === 'boolean' ||
|
|
311
|
+
typeof value === 'string' ||
|
|
312
|
+
(typeof value === 'number' && Number.isFinite(value)));
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
function analyticsFieldRowsToJson(rows) {
|
|
316
|
+
const fields = new Map();
|
|
317
|
+
for (const row of rows) {
|
|
318
|
+
const id = `${row.key}\u0000${row.type}`;
|
|
319
|
+
let field = fields.get(id);
|
|
320
|
+
if (!field) {
|
|
321
|
+
field = {
|
|
322
|
+
key: row.key,
|
|
323
|
+
type: row.type,
|
|
324
|
+
variantCount: Number(row.variant_count),
|
|
325
|
+
values: [],
|
|
326
|
+
};
|
|
327
|
+
fields.set(id, field);
|
|
328
|
+
}
|
|
329
|
+
if (field.variantCount <= 50) {
|
|
330
|
+
field.values.push({ value: row.value, count: Number(row.occurrence_count) });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return { items: [...fields.values()] };
|
|
334
|
+
}
|
|
335
|
+
function buildConversationConditions(query) {
|
|
336
|
+
const values = [];
|
|
337
|
+
const bind = (value) => {
|
|
338
|
+
values.push(value);
|
|
339
|
+
return `$${values.length}`;
|
|
340
|
+
};
|
|
341
|
+
const conditions = [];
|
|
342
|
+
const from = parseDateFilter(query.from, 'from');
|
|
343
|
+
const to = parseDateFilter(query.to, 'to');
|
|
344
|
+
if (from && to && from >= to) {
|
|
345
|
+
throw new AnalyticsQueryError('from must be earlier than to');
|
|
346
|
+
}
|
|
347
|
+
const agentId = requiredString(query.agentId, 'agentId');
|
|
348
|
+
const region = optionalString(query.region, 'region');
|
|
349
|
+
const channel = optionalString(query.channel, 'channel');
|
|
350
|
+
const phoneNumber = optionalString(query.phoneNumber, 'phoneNumber');
|
|
351
|
+
conditions.push(`c.agent_id = ${bind(agentId)}`);
|
|
352
|
+
if (region)
|
|
353
|
+
conditions.push(`c.region = ${bind(region)}`);
|
|
354
|
+
if (channel)
|
|
355
|
+
conditions.push(`c.channel = ${bind(channel)}`);
|
|
356
|
+
if (phoneNumber)
|
|
357
|
+
conditions.push(`p.phone_number = ${bind(phoneNumber)}`);
|
|
358
|
+
if (from)
|
|
359
|
+
conditions.push(`c.started_at >= ${bind(from)}`);
|
|
360
|
+
if (to)
|
|
361
|
+
conditions.push(`c.started_at < ${bind(to)}`);
|
|
362
|
+
const minDuration = parseDurationFilter(query.minDurationSeconds, 'minDurationSeconds');
|
|
363
|
+
const maxDuration = parseDurationFilter(query.maxDurationSeconds, 'maxDurationSeconds');
|
|
364
|
+
if (minDuration !== undefined)
|
|
365
|
+
conditions.push(`p.duration_seconds >= ${bind(minDuration)}`);
|
|
366
|
+
if (maxDuration !== undefined)
|
|
367
|
+
conditions.push(`p.duration_seconds <= ${bind(maxDuration)}`);
|
|
368
|
+
if (minDuration !== undefined && maxDuration !== undefined && minDuration > maxDuration) {
|
|
369
|
+
throw new AnalyticsQueryError('minDurationSeconds must not exceed maxDurationSeconds');
|
|
370
|
+
}
|
|
371
|
+
for (const filter of parseAnalyticsFilters(query.filters)) {
|
|
372
|
+
conditions.push(makeAnalyticsPredicate(filter, bind));
|
|
373
|
+
}
|
|
374
|
+
return { bind, conditions, from, to, values };
|
|
375
|
+
}
|
|
376
|
+
function makeAnalyticsPredicate(filter, bind) {
|
|
377
|
+
const key = bind(filter.key);
|
|
378
|
+
if (filter.operator === 'exists') {
|
|
379
|
+
return `c.analytics ? (${key}::text)`;
|
|
380
|
+
}
|
|
381
|
+
if (filter.operator === 'eq') {
|
|
382
|
+
return `c.analytics @> jsonb_build_object(${key}::text, ${bind(JSON.stringify(filter.value))}::jsonb)`;
|
|
383
|
+
}
|
|
384
|
+
if (filter.operator === 'in') {
|
|
385
|
+
const predicates = filter.values.map((value) => `c.analytics @> jsonb_build_object(${key}::text, ${bind(JSON.stringify(value))}::jsonb)`);
|
|
386
|
+
return `(${predicates.join(' OR ')})`;
|
|
387
|
+
}
|
|
388
|
+
const operator = { gt: '>', gte: '>=', lt: '<', lte: '<=' }[filter.operator];
|
|
389
|
+
return (`(CASE WHEN jsonb_typeof(c.analytics -> (${key}::text)) = 'number' THEN ` +
|
|
390
|
+
`(c.analytics ->> (${key}::text))::double precision ${operator} ${bind(filter.value)} ` +
|
|
391
|
+
'ELSE false END)');
|
|
392
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Pool, PoolConfig, QueryResult } from 'pg';
|
|
2
|
+
import { AnalyticsScalar, AnalyticsStore, ConversationAnalyticsSummary } from './analytics';
|
|
3
|
+
import { PostgresAnalyticsQueryService } from './postgres-analytics-query';
|
|
4
|
+
export interface ConversationRecord {
|
|
5
|
+
conversationId: string;
|
|
6
|
+
agentId: string;
|
|
7
|
+
channel: string;
|
|
8
|
+
region?: string;
|
|
9
|
+
startedAt: Date;
|
|
10
|
+
endedAt?: Date;
|
|
11
|
+
}
|
|
12
|
+
export interface PhoneCallRecord {
|
|
13
|
+
phoneNumber?: string;
|
|
14
|
+
recordingUrl?: string;
|
|
15
|
+
endedReason?: string;
|
|
16
|
+
durationSeconds?: number;
|
|
17
|
+
}
|
|
18
|
+
interface Queryable {
|
|
19
|
+
query<TResult extends Record<string, unknown> = Record<string, unknown>>(text: string, values?: unknown[]): Promise<QueryResult<TResult>>;
|
|
20
|
+
}
|
|
21
|
+
export declare class PostgresAnalytics implements AnalyticsStore {
|
|
22
|
+
private readonly database;
|
|
23
|
+
constructor(database: Queryable);
|
|
24
|
+
set(conversationId: string, agentId: string, fieldName: string, value: AnalyticsScalar): Promise<void>;
|
|
25
|
+
clear(conversationId: string, agentId: string, fieldName: string): Promise<void>;
|
|
26
|
+
get(conversationId: string, agentId: string, fieldName: string): Promise<unknown | undefined>;
|
|
27
|
+
getSummary(conversationId: string, agentId: string): Promise<ConversationAnalyticsSummary | undefined>;
|
|
28
|
+
}
|
|
29
|
+
export declare class PostgresConversationRepository {
|
|
30
|
+
private readonly pool;
|
|
31
|
+
constructor(pool: Pool);
|
|
32
|
+
saveConversation(conversation: ConversationRecord, phoneCall?: PhoneCallRecord): Promise<void>;
|
|
33
|
+
private upsertConversation;
|
|
34
|
+
private upsertPhoneCall;
|
|
35
|
+
}
|
|
36
|
+
export declare function migrateAnalyticsDatabase(pool: Pool): Promise<void>;
|
|
37
|
+
export interface PostgresAnalyticsServices {
|
|
38
|
+
analytics: PostgresAnalytics;
|
|
39
|
+
conversations: PostgresConversationRepository;
|
|
40
|
+
queries: PostgresAnalyticsQueryService;
|
|
41
|
+
close(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export declare function createPostgresAnalyticsServices(config: PoolConfig): Promise<PostgresAnalyticsServices>;
|
|
44
|
+
export {};
|
|
45
|
+
//# sourceMappingURL=postgres-analytics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"postgres-analytics.d.ts","sourceRoot":"","sources":["../src/postgres-analytics.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAc,UAAU,EAAE,WAAW,EAAE,MAAM,IAAI,CAAA;AAE9D,OAAO,EACH,eAAe,EACf,cAAc,EACd,4BAA4B,EAE/B,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,6BAA6B,EAAE,MAAM,4BAA4B,CAAA;AAI1E,MAAM,WAAW,kBAAkB;IAC/B,cAAc,EAAE,MAAM,CAAA;IACtB,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,IAAI,CAAA;IACf,OAAO,CAAC,EAAE,IAAI,CAAA;CACjB;AAID,MAAM,WAAW,eAAe;IAC5B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,eAAe,CAAC,EAAE,MAAM,CAAA;CAC3B;AAID,UAAU,SAAS;IACf,KAAK,CAAC,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACnE,IAAI,EAAE,MAAM,EACZ,MAAM,CAAC,EAAE,OAAO,EAAE,GACnB,OAAO,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC,CAAA;CACnC;AAaD,qBAAa,iBAAkB,YAAW,cAAc;IACxC,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAAR,QAAQ,EAAE,SAAS;IAE1C,GAAG,CACL,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,GACvB,OAAO,CAAC,IAAI,CAAC;IAeV,KAAK,CAAC,cAAc,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAchF,GAAG,CACL,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GAClB,OAAO,CAAC,OAAO,GAAG,SAAS,CAAC;IAezB,UAAU,CACZ,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,MAAM,GAChB,OAAO,CAAC,4BAA4B,GAAG,SAAS,CAAC;CAqBvD;AAgBD,qBAAa,8BAA8B;IAC3B,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,IAAI;IAEjC,gBAAgB,CAClB,YAAY,EAAE,kBAAkB,EAChC,SAAS,CAAC,EAAE,eAAe,GAC5B,OAAO,CAAC,IAAI,CAAC;YAmBF,kBAAkB;YAsClB,eAAe;CAgChC;AA2FD,wBAAsB,wBAAwB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAgDxE;AA6BD,MAAM,WAAW,yBAAyB;IACtC,SAAS,EAAE,iBAAiB,CAAA;IAC5B,aAAa,EAAE,8BAA8B,CAAA;IAC7C,OAAO,EAAE,6BAA6B,CAAA;IACtC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACzB;AAID,wBAAsB,+BAA+B,CACjD,MAAM,EAAE,UAAU,GACnB,OAAO,CAAC,yBAAyB,CAAC,CAgBpC"}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PostgresConversationRepository = exports.PostgresAnalytics = void 0;
|
|
4
|
+
exports.migrateAnalyticsDatabase = migrateAnalyticsDatabase;
|
|
5
|
+
exports.createPostgresAnalyticsServices = createPostgresAnalyticsServices;
|
|
6
|
+
const pg_1 = require("pg");
|
|
7
|
+
const analytics_1 = require("./analytics");
|
|
8
|
+
const postgres_analytics_query_1 = require("./postgres-analytics-query");
|
|
9
|
+
class PostgresAnalytics {
|
|
10
|
+
database;
|
|
11
|
+
constructor(database) {
|
|
12
|
+
this.database = database;
|
|
13
|
+
}
|
|
14
|
+
async set(conversationId, agentId, fieldName, value) {
|
|
15
|
+
(0, analytics_1.assertAnalyticsScalar)(value);
|
|
16
|
+
const serialized = JSON.stringify(value);
|
|
17
|
+
const result = await this.database.query(`UPDATE conversations
|
|
18
|
+
SET analytics = jsonb_set(analytics, ARRAY[$3::text], $4::jsonb, true),
|
|
19
|
+
updated_at = now()
|
|
20
|
+
WHERE conversation_id = $1
|
|
21
|
+
AND agent_id = $2`, [conversationId, agentId, fieldName, serialized]);
|
|
22
|
+
assertConversationBelongsToAgent(result.rowCount, conversationId, agentId);
|
|
23
|
+
}
|
|
24
|
+
async clear(conversationId, agentId, fieldName) {
|
|
25
|
+
const result = await this.database.query(`UPDATE conversations
|
|
26
|
+
SET analytics = analytics - $3::text,
|
|
27
|
+
updated_at = now()
|
|
28
|
+
WHERE conversation_id = $1
|
|
29
|
+
AND agent_id = $2`, [conversationId, agentId, fieldName]);
|
|
30
|
+
assertConversationBelongsToAgent(result.rowCount, conversationId, agentId);
|
|
31
|
+
}
|
|
32
|
+
async get(conversationId, agentId, fieldName) {
|
|
33
|
+
const result = await this.database.query(`SELECT analytics ? $3::text AS present,
|
|
34
|
+
analytics -> $3::text AS value
|
|
35
|
+
FROM conversations
|
|
36
|
+
WHERE conversation_id = $1
|
|
37
|
+
AND agent_id = $2`, [conversationId, agentId, fieldName]);
|
|
38
|
+
if (result.rows.length === 0 || !result.rows[0].present) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return result.rows[0].value;
|
|
42
|
+
}
|
|
43
|
+
async getSummary(conversationId, agentId) {
|
|
44
|
+
const result = await this.database.query(`SELECT conversation_id, agent_id, channel, started_at
|
|
45
|
+
FROM conversations
|
|
46
|
+
WHERE conversation_id = $1
|
|
47
|
+
AND agent_id = $2`, [conversationId, agentId]);
|
|
48
|
+
const row = result.rows[0];
|
|
49
|
+
if (!row) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
conversationId: row.conversation_id,
|
|
54
|
+
agentId: row.agent_id,
|
|
55
|
+
channel: row.channel,
|
|
56
|
+
startedAt: row.started_at,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
exports.PostgresAnalytics = PostgresAnalytics;
|
|
61
|
+
function assertConversationBelongsToAgent(rowCount, conversationId, agentId) {
|
|
62
|
+
if (rowCount !== 1) {
|
|
63
|
+
throw new Error(`Conversation '${conversationId}' does not belong to agent '${agentId}'`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
class PostgresConversationRepository {
|
|
67
|
+
pool;
|
|
68
|
+
constructor(pool) {
|
|
69
|
+
this.pool = pool;
|
|
70
|
+
}
|
|
71
|
+
async saveConversation(conversation, phoneCall) {
|
|
72
|
+
const client = await this.pool.connect();
|
|
73
|
+
try {
|
|
74
|
+
await client.query('BEGIN');
|
|
75
|
+
await this.upsertConversation(client, conversation);
|
|
76
|
+
if (phoneCall) {
|
|
77
|
+
await this.upsertPhoneCall(client, conversation.conversationId, phoneCall);
|
|
78
|
+
}
|
|
79
|
+
await client.query('COMMIT');
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
await client.query('ROLLBACK');
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
client.release();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async upsertConversation(client, conversation) {
|
|
90
|
+
const result = await client.query(`INSERT INTO conversations (
|
|
91
|
+
conversation_id,
|
|
92
|
+
agent_id,
|
|
93
|
+
channel,
|
|
94
|
+
region,
|
|
95
|
+
started_at,
|
|
96
|
+
ended_at
|
|
97
|
+
) VALUES ($1, $2, $3, $4, $5, $6)
|
|
98
|
+
ON CONFLICT (conversation_id) DO UPDATE SET
|
|
99
|
+
agent_id = COALESCE(conversations.agent_id, EXCLUDED.agent_id),
|
|
100
|
+
region = COALESCE(conversations.region, EXCLUDED.region),
|
|
101
|
+
ended_at = COALESCE(conversations.ended_at, EXCLUDED.ended_at),
|
|
102
|
+
updated_at = now()
|
|
103
|
+
WHERE conversations.agent_id IS NULL
|
|
104
|
+
OR conversations.agent_id = EXCLUDED.agent_id`, [
|
|
105
|
+
conversation.conversationId,
|
|
106
|
+
conversation.agentId,
|
|
107
|
+
conversation.channel,
|
|
108
|
+
conversation.region ?? null,
|
|
109
|
+
conversation.startedAt,
|
|
110
|
+
conversation.endedAt ?? null,
|
|
111
|
+
]);
|
|
112
|
+
assertConversationBelongsToAgent(result.rowCount, conversation.conversationId, conversation.agentId);
|
|
113
|
+
}
|
|
114
|
+
async upsertPhoneCall(client, conversationId, phoneCall) {
|
|
115
|
+
await client.query(`INSERT INTO phone_calls (
|
|
116
|
+
conversation_id,
|
|
117
|
+
phone_number,
|
|
118
|
+
recording_url,
|
|
119
|
+
ended_reason,
|
|
120
|
+
duration_seconds
|
|
121
|
+
) VALUES ($1, $2, $3, $4, $5)
|
|
122
|
+
ON CONFLICT (conversation_id) DO UPDATE SET
|
|
123
|
+
phone_number = COALESCE(phone_calls.phone_number, EXCLUDED.phone_number),
|
|
124
|
+
recording_url = COALESCE(phone_calls.recording_url, EXCLUDED.recording_url),
|
|
125
|
+
ended_reason = COALESCE(phone_calls.ended_reason, EXCLUDED.ended_reason),
|
|
126
|
+
duration_seconds = COALESCE(
|
|
127
|
+
phone_calls.duration_seconds,
|
|
128
|
+
EXCLUDED.duration_seconds
|
|
129
|
+
)`, [
|
|
130
|
+
conversationId,
|
|
131
|
+
phoneCall.phoneNumber ?? null,
|
|
132
|
+
phoneCall.recordingUrl ?? null,
|
|
133
|
+
phoneCall.endedReason ?? null,
|
|
134
|
+
phoneCall.durationSeconds ?? null,
|
|
135
|
+
]);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
exports.PostgresConversationRepository = PostgresConversationRepository;
|
|
139
|
+
const migrations = [
|
|
140
|
+
{
|
|
141
|
+
version: 1,
|
|
142
|
+
sql: `
|
|
143
|
+
-- Analytics rationale: This is the channel-neutral parent row and top-level JSONB map.
|
|
144
|
+
-- Removing it leaves typed values with no lifecycle or common dimensions to attach to.
|
|
145
|
+
CREATE TABLE conversations (
|
|
146
|
+
conversation_id text PRIMARY KEY,
|
|
147
|
+
agent_id text,
|
|
148
|
+
channel text NOT NULL,
|
|
149
|
+
region text,
|
|
150
|
+
started_at timestamptz NOT NULL,
|
|
151
|
+
ended_at timestamptz,
|
|
152
|
+
duration_seconds double precision CHECK (
|
|
153
|
+
duration_seconds IS NULL OR duration_seconds >= 0
|
|
154
|
+
),
|
|
155
|
+
analytics jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (
|
|
156
|
+
jsonb_typeof(analytics) = 'object'
|
|
157
|
+
),
|
|
158
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
159
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
-- Analytics rationale: Phone-only data stays in a one-to-one child so text conversations
|
|
163
|
+
-- do not acquire call-specific columns or assumptions.
|
|
164
|
+
CREATE TABLE phone_calls (
|
|
165
|
+
conversation_id text PRIMARY KEY REFERENCES conversations(conversation_id)
|
|
166
|
+
ON DELETE CASCADE,
|
|
167
|
+
phone_number text,
|
|
168
|
+
recording_url text,
|
|
169
|
+
ended_reason text
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
-- Analytics rationale: These indexes cover time-ordered listing and its common equality
|
|
173
|
+
-- filters; without them dashboards and Telescope lists degrade to full table scans.
|
|
174
|
+
CREATE INDEX conversations_started_at_idx
|
|
175
|
+
ON conversations (started_at DESC, conversation_id DESC);
|
|
176
|
+
CREATE INDEX conversations_agent_started_at_idx
|
|
177
|
+
ON conversations (agent_id, started_at DESC, conversation_id DESC);
|
|
178
|
+
CREATE INDEX conversations_region_started_at_idx
|
|
179
|
+
ON conversations (region, started_at DESC, conversation_id DESC);
|
|
180
|
+
CREATE INDEX conversations_channel_started_at_idx
|
|
181
|
+
ON conversations (channel, started_at DESC, conversation_id DESC);
|
|
182
|
+
|
|
183
|
+
-- Analytics rationale: GIN accelerates JSONB containment filters while the phone index
|
|
184
|
+
-- supports exact caller lookups joined through the conversation ID.
|
|
185
|
+
CREATE INDEX conversations_analytics_idx
|
|
186
|
+
ON conversations USING gin (analytics);
|
|
187
|
+
CREATE INDEX phone_calls_phone_number_idx
|
|
188
|
+
ON phone_calls (phone_number, conversation_id);
|
|
189
|
+
`,
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
version: 2,
|
|
193
|
+
sql: `
|
|
194
|
+
-- Analytics rationale: Enforce mandatory agent ownership for new and updated rows
|
|
195
|
+
-- without making deployment fail on historical conversations that have no agent ID.
|
|
196
|
+
ALTER TABLE conversations
|
|
197
|
+
ADD CONSTRAINT conversations_agent_id_required
|
|
198
|
+
CHECK (agent_id IS NOT NULL AND btrim(agent_id) <> '') NOT VALID;
|
|
199
|
+
`,
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
version: 3,
|
|
203
|
+
sql: `
|
|
204
|
+
-- Analytics rationale: Duration describes a phone call, not every conversation. Move
|
|
205
|
+
-- existing call durations before removing the phone-specific column from the parent.
|
|
206
|
+
ALTER TABLE phone_calls
|
|
207
|
+
ADD COLUMN duration_seconds double precision CHECK (
|
|
208
|
+
duration_seconds IS NULL OR duration_seconds >= 0
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
UPDATE phone_calls AS p
|
|
212
|
+
SET duration_seconds = c.duration_seconds
|
|
213
|
+
FROM conversations AS c
|
|
214
|
+
WHERE p.conversation_id = c.conversation_id
|
|
215
|
+
AND p.duration_seconds IS NULL
|
|
216
|
+
AND c.duration_seconds IS NOT NULL;
|
|
217
|
+
|
|
218
|
+
ALTER TABLE conversations
|
|
219
|
+
DROP COLUMN duration_seconds;
|
|
220
|
+
`,
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
async function migrateAnalyticsDatabase(pool) {
|
|
224
|
+
const client = await pool.connect();
|
|
225
|
+
let lockAcquired = false;
|
|
226
|
+
let migrationFailed = false;
|
|
227
|
+
try {
|
|
228
|
+
await client.query("SELECT pg_advisory_lock(hashtext('recombine_analytics_migrations'))");
|
|
229
|
+
lockAcquired = true;
|
|
230
|
+
await client.query(`
|
|
231
|
+
CREATE TABLE IF NOT EXISTS analytics_schema_migrations (
|
|
232
|
+
version integer PRIMARY KEY,
|
|
233
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
234
|
+
)
|
|
235
|
+
`);
|
|
236
|
+
for (const migration of migrations) {
|
|
237
|
+
const applied = await client.query(`SELECT EXISTS (
|
|
238
|
+
SELECT 1 FROM analytics_schema_migrations WHERE version = $1
|
|
239
|
+
) AS exists`, [migration.version]);
|
|
240
|
+
if (applied.rows[0].exists) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
await client.query('BEGIN');
|
|
244
|
+
try {
|
|
245
|
+
await client.query(migration.sql);
|
|
246
|
+
await client.query('INSERT INTO analytics_schema_migrations (version) VALUES ($1)', [migration.version]);
|
|
247
|
+
await client.query('COMMIT');
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
await client.query('ROLLBACK');
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
migrationFailed = true;
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
259
|
+
finally {
|
|
260
|
+
await releaseAnalyticsMigrationClient(client, lockAcquired, migrationFailed);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async function releaseAnalyticsMigrationClient(client, lockAcquired, migrationFailed) {
|
|
264
|
+
let discardClient = false;
|
|
265
|
+
try {
|
|
266
|
+
if (lockAcquired) {
|
|
267
|
+
await client.query("SELECT pg_advisory_unlock(hashtext('recombine_analytics_migrations'))");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
discardClient = true;
|
|
272
|
+
if (!migrationFailed) {
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
client.release(discardClient);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function createPostgresAnalyticsServices(config) {
|
|
281
|
+
const pool = new pg_1.Pool(config);
|
|
282
|
+
try {
|
|
283
|
+
await migrateAnalyticsDatabase(pool);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
await pool.end();
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
analytics: new PostgresAnalytics(pool),
|
|
291
|
+
conversations: new PostgresConversationRepository(pool),
|
|
292
|
+
queries: new postgres_analytics_query_1.PostgresAnalyticsQueryService(pool),
|
|
293
|
+
close: () => pool.end(),
|
|
294
|
+
};
|
|
295
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@recombine-ai/platform",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6-test.10",
|
|
4
4
|
"description": "Recombine AI: voice agent platform",
|
|
5
5
|
"license": "EULA",
|
|
6
6
|
"author": "recombine.ai",
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@recombine-ai/engine": "1.0.0",
|
|
19
19
|
"@recombine-ai/telescope": "^0.3.0",
|
|
20
|
+
"@types/pg": "8.15.5",
|
|
21
|
+
"pg": "8.16.3",
|
|
20
22
|
"redis": "5.11.0",
|
|
21
23
|
"zod": "^4.4.3"
|
|
22
24
|
},
|